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
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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"
}
}
6 changes: 5 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 - Minor]

### 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

Expand Down
93 changes: 7 additions & 86 deletions packages/cli/src/cli/lib/project-workspace-key.ts
Original file line number Diff line number Diff line change
@@ -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<ProjectWorkspaceKeyFile>;
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';
37 changes: 10 additions & 27 deletions packages/cli/src/cli/lib/sdk-client.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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:
Expand All @@ -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 <name> <key>`.'
);
Expand All @@ -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);
}
Expand Down
4 changes: 4 additions & 0 deletions packages/cloud/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
10 changes: 10 additions & 0 deletions packages/cloud/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
82 changes: 82 additions & 0 deletions packages/cloud/src/project-workspace-key.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading
Loading