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
17 changes: 15 additions & 2 deletions packages/api/src/config/governance/list-all-projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@

import { readdir, realpath, stat } from 'node:fs/promises';
import { dirname, join, resolve } from 'node:path';

import { resolvePersistentProjectPathDetailed } from '../../utils/persistent-project-path.js';
import { pathsEqual } from '../../utils/project-path.js';
import { GovernanceRegistry } from './governance-registry.js';

/**
Expand Down Expand Up @@ -84,7 +85,19 @@ export async function listAllProjectPaths(catCafeRoot: string, opts?: { maxScanD
const visitedDirs = new Set<string>();
await scanNestedProjects(catCafeRoot, maxDepth, seen, result, visitedDirs);

return result;
const rootResolution = await resolvePersistentProjectPathDetailed(catCafeRoot);
const persistentRoot = rootResolution.ok ? rootResolution.path : resolvedRoot;
const canonicalResult: string[] = [];
for (const projectPath of result) {
const resolution = await resolvePersistentProjectPathDetailed(projectPath);
if (!resolution.ok) continue;
const persistentPath = resolution.remappedFrom ? resolution.path : projectPath;
if (pathsEqual(resolution.path, persistentRoot)) continue;
if (!canonicalResult.some((existing) => pathsEqual(existing, persistentPath))) {
canonicalResult.push(persistentPath);
}
}
return canonicalResult;
}

async function scanNestedProjects(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,8 @@ import { resolveActiveProjectRoot } from '../../../../../utils/active-project-ro
import { resolveCliCommand } from '../../../../../utils/cli-resolve.js';
import { DEFAULT_CLI_TIMEOUT_MS, resolveCliTimeoutMs } from '../../../../../utils/cli-timeout.js';
import { findMonorepoRoot, isSameProject } from '../../../../../utils/monorepo-root.js';
import { pathsEqual, validateProjectPathDetailed } from '../../../../../utils/project-path.js';
import { resolvePersistentProjectPathDetailed } from '../../../../../utils/persistent-project-path.js';
import { pathsEqual } from '../../../../../utils/project-path.js';
import { tcpProbe } from '../../../../../utils/tcp-probe.js';
import type { AgentPaneRegistry } from '../../../../terminal/agent-pane-registry.js';
import type { TmuxGateway } from '../../../../terminal/tmux-gateway.js';
Expand Down Expand Up @@ -1216,9 +1217,11 @@ export async function* invokeSingleCat(deps: InvocationDeps, params: InvocationP
if (thread.projectPath.startsWith('games/')) {
workspaceResolutionFailureMessage = `OpenCode requires a filesystem thread projectPath for ${threadId}; virtual game projectPath ${thread.projectPath} cannot be used as a working directory.`;
} else {
const validatedProjectPath = await validateProjectPathDetailed(thread.projectPath);
const validatedProjectPath = await resolvePersistentProjectPathDetailed(thread.projectPath);
if (!validatedProjectPath.ok) {
const isTransient = validatedProjectPath.reason === 'io_error';
const isTransient = ['io_error', 'runtime_root_invalid', 'runtime_workspace_missing'].includes(
validatedProjectPath.reason,
);
workspaceResolutionFailureMessage = isTransient
? `Unable to validate thread projectPath for ${threadId}: ${thread.projectPath}. ${validatedProjectPath.message ?? 'Transient filesystem error.'} Retry; if it persists, re-bind the thread's project workspace.`
: `Invalid thread projectPath for ${threadId}: ${thread.projectPath}. Expected an existing directory under allowed roots.`;
Expand All @@ -1234,6 +1237,29 @@ export async function* invokeSingleCat(deps: InvocationDeps, params: InvocationP
);
} else {
workingDirectory = validatedProjectPath.path;
if (validatedProjectPath.remappedFrom && threadStore.updateProjectPath) {
try {
await preflightRace(
Promise.resolve(threadStore.updateProjectPath(threadId, validatedProjectPath.path)),
'updateProjectPath',
signal,
);
log.info(
{
catId,
threadId,
previousProjectPath: validatedProjectPath.remappedFrom,
projectPath: validatedProjectPath.path,
},
'migrated thread projectPath out of the runtime worktree',
);
} catch (migrationErr) {
log.warn(
{ catId, threadId, err: migrationErr, projectPath: validatedProjectPath.path },
'failed to persist runtime projectPath migration; continuing with persistent workspace',
);
}
}
}
}
} else if (thread?.bootcampState) {
Expand Down
11 changes: 7 additions & 4 deletions packages/api/src/routes/accounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { deleteCredential, hasCredential, writeCredential } from '../config/cred

import { resolveActiveProjectRoot } from '../utils/active-project-root.js';
import { findMonorepoRoot } from '../utils/monorepo-root.js';
import { validateProjectPath } from '../utils/project-path.js';
import { redirectRuntimeProjectPath, resolvePersistentProjectPathDetailed } from '../utils/persistent-project-path.js';
import { resolveUserId } from '../utils/request-identity.js';

// clowder-ai#340: Derive client identity from well-known account IDs, not stored protocol.
Expand Down Expand Up @@ -199,9 +199,12 @@ const deleteBodySchema = z.object({
});

async function resolveProjectRoot(projectPath?: string): Promise<string | null> {
if (!projectPath) return resolveActiveProjectRoot();
const validated = await validateProjectPath(projectPath);
if (validated) return validated;
if (!projectPath) return redirectRuntimeProjectPath(resolveActiveProjectRoot());
const persistent = await resolvePersistentProjectPathDetailed(projectPath);
if (persistent.ok) return persistent.path;
if (['runtime_root_invalid', 'runtime_workspace_missing', 'runtime_target_unmappable'].includes(persistent.reason)) {
return null;
}

// Workspace project switcher can provide sibling repo paths (outside homedir/tmp allowlist).
// Allow paths under current workspace root while keeping realpath boundary checks.
Expand Down
4 changes: 2 additions & 2 deletions packages/api/src/routes/agent-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type { FastifyPluginAsync, FastifyRequest } from 'fastify';
import { getAgentHookStatus, syncAgentHooks } from '../agent-hooks/index.js';
import { findMonorepoRoot } from '../utils/monorepo-root.js';
import { resolveOwnerGate } from '../utils/owner-gate.js';
import { validateProjectPath } from '../utils/project-path.js';
import { resolvePersistentProjectPath } from '../utils/persistent-project-path.js';

export interface AgentHooksRouteOptions {
projectRoot?: string;
Expand Down Expand Up @@ -88,7 +88,7 @@ async function validateExplicitProjectPath(
): Promise<{ ok: true; path: string | null } | { ok: false; error: string }> {
if (!rawPath) return { ok: true, path: null };

const validated = await validateProjectPath(rawPath);
const validated = await resolvePersistentProjectPath(rawPath);
if (!validated) {
return { ok: false, error: `Invalid project path: not found, denied, or not a directory: ${rawPath}` };
}
Expand Down
13 changes: 10 additions & 3 deletions packages/api/src/routes/callback-propose-thread-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import type { IProposalStore } from '../domains/cats/services/stores/ports/Propo
import type { IThreadStore } from '../domains/cats/services/stores/ports/ThreadStore.js';
import type { SocketManager } from '../infrastructure/websocket/index.js';
import { normalizeCatIdMentionsInText } from '../utils/cat-mention-handle.js';
import { validateProjectPath } from '../utils/project-path.js';
import { migrateStoredProjectPath, resolvePersistentProjectPath } from '../utils/persistent-project-path.js';
import { requireCallbackAuth } from './callback-auth-prehandler.js';
import { buildProposalCardBlock } from './proposal-card-block.js';

Expand All @@ -31,7 +31,7 @@ const proposeThreadCallbackSchema = z.object({
// F128 Phase AA (AC-AA1): reporting contract. Omitted → default final-only (supersedes Phase Y AC-Y6 none).
reportingMode: z.enum(['none', 'final-only', 'state-transitions', 'blocking-ack']).optional(),
// F128: explicit project ownership for the child thread. Validated against allowed roots
// (validateProjectPath) — NOT hardcoded. Omitted → inherit source thread's projectPath;
// (resolvePersistentProjectPath) — NOT hardcoded. Omitted → inherit source thread's projectPath;
// supplied-but-invalid → 400 (fail loud, never silently fall back to default).
projectPath: z.string().min(1).max(500).optional(),
parentThreadId: z.string().min(1).optional(),
Expand Down Expand Up @@ -148,12 +148,19 @@ export function registerCallbackProposeThreadRoutes(app: FastifyInstance, deps:
// thinks it pinned `clowder-ai` would land in `default` (砚砚 review push-back #1).
let resolvedProjectPath = parentThread.projectPath; // omitted → inherit effective parent thread
if (explicitProjectPath !== undefined) {
const validatedProjectPath = await validateProjectPath(explicitProjectPath);
const validatedProjectPath = await resolvePersistentProjectPath(explicitProjectPath);
if (!validatedProjectPath) {
reply.status(400);
return { error: 'Invalid projectPath: must be an existing directory under allowed roots' };
}
resolvedProjectPath = validatedProjectPath; // supplied & valid → canonical real path
} else if (resolvedProjectPath && resolvedProjectPath !== 'default') {
const inheritedProjectPath = await migrateStoredProjectPath(resolvedProjectPath);
if (!inheritedProjectPath) {
reply.status(400);
return { error: 'Inherited projectPath no longer resolves to a persistent workspace' };
}
resolvedProjectPath = inheritedProjectPath;
}

// P2: Reserve dedup BEFORE create. Pre-generate a candidate proposalId, then atomically
Expand Down
14 changes: 7 additions & 7 deletions packages/api/src/routes/capabilities-mcp-write.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ import {
resolveCapabilityWriteSessionUserId,
} from '../config/capabilities/capability-write-guards.js';
import { syncMcpAll } from '../mcp/mcp-sync-all.js';
import { validateProjectPath } from '../utils/project-path.js';
import { resolvePersistentProjectPath } from '../utils/persistent-project-path.js';
import { resolveUserId } from '../utils/request-identity.js';
import { resolveMainRepoPath } from '../utils/skill-mount.js';
import { type McpProbeResult, probeMcpCapability } from './mcp-probe.js';
Expand Down Expand Up @@ -178,7 +178,7 @@ export const capabilitiesMcpWriteRoutes: FastifyPluginAsync<{

let projectRoot = getProjectRoot();
if (body.projectPath) {
const validated = await validateProjectPath(body.projectPath);
const validated = await resolvePersistentProjectPath(body.projectPath);
if (!validated) {
reply.status(400);
return { error: 'Invalid project path' };
Expand Down Expand Up @@ -228,7 +228,7 @@ export const capabilitiesMcpWriteRoutes: FastifyPluginAsync<{

let projectRoot = getProjectRoot();
if (body.projectPath) {
const validated = await validateProjectPath(body.projectPath);
const validated = await resolvePersistentProjectPath(body.projectPath);
if (!validated) {
reply.status(400);
return { error: 'Invalid project path' };
Expand Down Expand Up @@ -378,7 +378,7 @@ export const capabilitiesMcpWriteRoutes: FastifyPluginAsync<{

let projectRoot = getProjectRoot();
if (query.projectPath) {
const validated = await validateProjectPath(query.projectPath);
const validated = await resolvePersistentProjectPath(query.projectPath);
if (!validated) {
reply.status(400);
return { error: 'Invalid project path' };
Expand Down Expand Up @@ -515,7 +515,7 @@ export const capabilitiesMcpWriteRoutes: FastifyPluginAsync<{

let projectRoot = getProjectRoot();
if (body?.projectPath) {
const validated = await validateProjectPath(body.projectPath);
const validated = await resolvePersistentProjectPath(body.projectPath);
if (!validated) {
reply.status(400);
return { error: 'Invalid project path' };
Expand Down Expand Up @@ -588,7 +588,7 @@ export const capabilitiesMcpWriteRoutes: FastifyPluginAsync<{
let projectRoot = getProjectRoot();
const body = request.body as { projectPath?: string } | undefined;
if (body?.projectPath) {
const validated = await validateProjectPath(body.projectPath);
const validated = await resolvePersistentProjectPath(body.projectPath);
if (!validated) {
reply.status(400);
return { error: 'Invalid project path' };
Expand Down Expand Up @@ -662,7 +662,7 @@ export const capabilitiesMcpWriteRoutes: FastifyPluginAsync<{
let projectRoot = getProjectRoot();
const query = request.query as { projectPath?: string; limit?: string };
if (query.projectPath) {
const validated = await validateProjectPath(query.projectPath);
const validated = await resolvePersistentProjectPath(query.projectPath);
if (!validated) {
reply.status(400);
return { error: 'Invalid project path' };
Expand Down
38 changes: 22 additions & 16 deletions packages/api/src/routes/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,12 @@ import {
} from '../skills/skill-meta.js';
import { syncAll } from '../skills/skill-sync-all.js';
import { type MountConflict, syncProject } from '../skills/skill-sync-engine.js';
import { pathsEqual, validateProjectPath } from '../utils/project-path.js';
import {
redirectRuntimeProjectPath,
resolvePersistentProjectPath,
validateExternalProjectPathDetailed,
} from '../utils/persistent-project-path.js';
import { pathsEqual } from '../utils/project-path.js';
import { resolveUserId } from '../utils/request-identity.js';
import {
buildMountPointDirCandidates,
Expand Down Expand Up @@ -280,10 +285,6 @@ function findMonorepoRoot(): string {

const PROJECT_ROOT = findMonorepoRoot();

function getProjectRoot(): string {
return PROJECT_ROOT;
}

export async function buildKnownProjectPaths(
catCafeRoot: string,
projectRoot: string,
Expand Down Expand Up @@ -376,7 +377,7 @@ function resolveCatCafeSkillsSourceDir(): string {
if (existsSync(candidate)) return join(dir, 'cat-cafe-skills');
dir = dirname(dir);
}
return join(getProjectRoot(), 'cat-cafe-skills');
return join(PROJECT_ROOT, 'cat-cafe-skills');
}

const CAT_CAFE_SKILLS_SRC = resolveCatCafeSkillsSourceDir();
Expand Down Expand Up @@ -531,6 +532,10 @@ function buildCatFamilies(): CatFamily[] {
// ────────── Route Plugin ──────────

export const capabilitiesRoutes: FastifyPluginAsync = async (app) => {
const persistentProjectRoot = await redirectRuntimeProjectPath(PROJECT_ROOT);
if (!persistentProjectRoot) throw new Error('Unable to resolve persistent global capabilities root');
const getProjectRoot = (): string => persistentProjectRoot;

// ── GET /api/capabilities ──
app.get('/api/capabilities', async (request, reply) => {
const userId = resolveUserId(request);
Expand All @@ -545,7 +550,7 @@ export const capabilitiesRoutes: FastifyPluginAsync = async (app) => {
const includeMcpLaunchFields = canReadSensitiveMcpConfig(request);
let projectRoot = getProjectRoot();
if (query.projectPath) {
const validated = await validateProjectPath(query.projectPath);
const validated = await resolvePersistentProjectPath(query.projectPath);
if (!validated) {
reply.status(400);
return { error: 'Invalid project path: must be an existing directory under allowed roots' };
Expand Down Expand Up @@ -1155,7 +1160,7 @@ export const capabilitiesRoutes: FastifyPluginAsync = async (app) => {
const mainProjectRoot = getProjectRoot();
let selectedProjectRoot = mainProjectRoot;
if (body.projectPath) {
const validated = await validateProjectPath(body.projectPath);
const validated = await resolvePersistentProjectPath(body.projectPath);
if (!validated) {
reply.status(400);
return { error: 'Invalid project path: must be an existing directory under allowed roots' };
Expand Down Expand Up @@ -1511,17 +1516,18 @@ export const capabilitiesRoutes: FastifyPluginAsync = async (app) => {
return { error: 'Required: projectPath' };
}

const validated = await validateProjectPath(body.projectPath);
if (!validated) {
reply.status(400);
return { error: 'Invalid project path' };
}

const catCafeRoot = getProjectRoot();
if (validated === catCafeRoot) {
const validatedResult = await validateExternalProjectPathDetailed(body.projectPath, catCafeRoot);
if (!validatedResult.ok) {
reply.status(400);
return { error: 'Cannot confirm governance for Clowder AI itself' };
return {
error:
validatedResult.reason === 'cat_cafe_owned_path'
? 'Cannot confirm governance inside Clowder AI; choose an external project'
: 'Invalid project path',
};
}
const validated = validatedResult.path;

const { GovernanceBootstrapService } = await import('../config/governance/governance-bootstrap.js');
const service = new GovernanceBootstrapService(catCafeRoot);
Expand Down
Loading
Loading