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
30 changes: 30 additions & 0 deletions src/api-routes.fs-roots.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,36 @@ describe('api fs roots', () => {
}
});

it('publishes the authenticated user workspace root', async () => {
const app = Fastify();
const userId = 'workspace-user-uuid';
try {
await registerApiRoutes(app, {
jwtSecret: 'test-secret',
config: {
server: { auth_mode: 'none', host: '127.0.0.1' },
security: { enterprise: {} },
http_rate_limit: { global_rpm: 100, auth_rpm: 10, pair_rpm: 5 },
},
});

const response = await app.inject({
method: 'GET',
url: '/api/fs/roots',
headers: {
authorization: `Bearer ${jwtSign(userId, 'test-secret', 3600, { tenant_id: 'default', roles: ['admin'] })}`,
},
});

expect(response.statusCode).toBe(200);
expect(response.json().roots).toEqual(expect.arrayContaining([
expect.objectContaining({ tier: 'workspace', path: getWorkspaceDir(userId) }),
]));
} finally {
await app.close();
}
});

it('joins registry identity onto deliverables and leaves legacy files null', async () => {
const app = Fastify();
const tenantId = 'default';
Expand Down
9 changes: 5 additions & 4 deletions src/api/application-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1631,8 +1631,9 @@ export async function registerApiRoutes(
});

// ── REST API: logical filesystem roots ──
app.get('/api/fs/roots', async (_request, reply) => {
return reply.send({ success: true, roots: listFsRoots() });
app.get('/api/fs/roots', async (request, reply) => {
const tenantContext = getRequestTenantContext(request as { tenantContext?: ApiTenantContext });
return reply.send({ success: true, roots: listFsRoots(tenantContext?.user_id) });
});

// The cross-session deliverable library (operator decision 2026-07-19):
Expand Down Expand Up @@ -2282,7 +2283,7 @@ export async function registerApiRoutes(

try {
const grant = grantProjectRoot(parsed.data.path);
const roots = listFsRoots();
const roots = listFsRoots(tenantContext?.user_id);
logAudit({
tenant_id: tenantContext?.tenant_id ?? 'default',
user_id: tenantContext?.user_id,
Expand Down Expand Up @@ -2330,7 +2331,7 @@ export async function registerApiRoutes(
ip_address: request.ip,
user_agent: request.headers['user-agent'],
});
return reply.send({ success: true, revoked, roots: listFsRoots() });
return reply.send({ success: true, revoked, roots: listFsRoots(tenantContext?.user_id) });
});

app.get('/api/runtime/logs', async (request, reply) => {
Expand Down
8 changes: 6 additions & 2 deletions src/background-executor/handlers/managed-brain.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,17 @@ const hoisted = vi.hoisted(() => ({
getById: vi.fn(),
openCronRunSession: vi.fn(),
broadcastSessionListChanged: vi.fn(),
loadSystemPrompt: vi.fn(() => 'system prompt'),
loadDelegationSystemPrompt: vi.fn(() => 'delegation prompt'),
}));

vi.mock('../../config/index.js', () => ({ getConfig: () => ({}) }));
vi.mock('../../core/model-router.js', () => ({
getBrainClient: () => ({ client: { chat: vi.fn() } }),
}));
vi.mock('../../system-prompt.js', () => ({
loadSystemPrompt: () => 'system prompt',
loadDelegationSystemPrompt: () => 'delegation prompt',
loadSystemPrompt: hoisted.loadSystemPrompt,
loadDelegationSystemPrompt: hoisted.loadDelegationSystemPrompt,
adaptPromptForChannel: (prompt: string) => prompt,
}));
vi.mock('../../gateway/handler.js', () => ({ handleMessage: hoisted.handleMessage }));
Expand Down Expand Up @@ -108,6 +110,8 @@ describe('managed Brain scheduled handler', () => {
planDeliveryMode: 'caller',
suppressAssistantMessagePersistence: true,
});
expect(hoisted.loadSystemPrompt).toHaveBeenCalledWith(expect.anything(), 'default', 'local-user');
expect(hoisted.loadDelegationSystemPrompt).toHaveBeenCalledWith(expect.anything(), 'default', 'local-user');
await vi.waitFor(() => expect(hoisted.broadcastSessionListChanged).toHaveBeenCalledWith({
targetUserId: 'local-user', tenantId: 'default', sessionId: 'sess-run',
}));
Expand Down
4 changes: 2 additions & 2 deletions src/background-executor/handlers/managed-brain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ export async function managedBrainHandler(task: BackgroundTask, signal: AbortSig
});

const config = getConfig();
const delegationSystemPrompt = loadDelegationSystemPrompt(config, task.tenant_id);
const delegationSystemPrompt = loadDelegationSystemPrompt(config, task.tenant_id, run.userId);
const existingRootId = typeof params.managed_plan_root_id === 'string'
? params.managed_plan_root_id.trim()
: '';
Expand Down Expand Up @@ -139,7 +139,7 @@ export async function managedBrainHandler(task: BackgroundTask, signal: AbortSig
const { client } = getBrainClient({ tenantId: task.tenant_id, userId: task.user_id });
const response = await handleMessage(
msg,
adaptPromptForChannel(loadSystemPrompt(config, task.tenant_id), task.channel_type),
adaptPromptForChannel(loadSystemPrompt(config, task.tenant_id, run.userId), task.channel_type),
client,
progress,
undefined,
Expand Down
14 changes: 8 additions & 6 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -378,16 +378,18 @@ export function createMessageHandler(
return /aborted|cancelled|canceled|timed out/i.test(message);
};

const resolveSystemPrompt = (tenantId?: string): string => {
const resolveSystemPrompt = (tenantId?: string, userId?: string): string => {
const effectiveTenantId = resolveTenantId(tenantId);
const prompt = loadSystemPrompt(config, effectiveTenantId);
const promptKey = `${effectiveTenantId}:${userId ?? ''}`;
const prompt = loadSystemPrompt(config, effectiveTenantId, userId);
const fingerprint = createHash('sha1').update(prompt).digest('hex');
const previousFingerprint = promptFingerprints.get(effectiveTenantId);
const previousFingerprint = promptFingerprints.get(promptKey);
if (fingerprint !== previousFingerprint) {
promptFingerprints.set(effectiveTenantId, fingerprint);
promptFingerprints.set(promptKey, fingerprint);
logger.info(
{
tenantId: effectiveTenantId,
userId,
promptLength: prompt.length,
tools: getAllRegisteredTools(effectiveTenantId).length,
},
Expand Down Expand Up @@ -492,8 +494,8 @@ export function createMessageHandler(
handlerRef = async (msg: IncomingMessage): Promise<string | null> => {
const executeMessage = async (): Promise<string | null> => {
const tenantId = msg.tenantId ?? process.env.MOZI_TENANT_ID ?? 'default';
const rawSystemPrompt = resolveSystemPrompt(tenantId);
const delegationSystemPrompt = loadDelegationSystemPrompt(config, tenantId);
const rawSystemPrompt = resolveSystemPrompt(tenantId, msg.userId);
const delegationSystemPrompt = loadDelegationSystemPrompt(config, tenantId, msg.userId);
const systemPromptContent = adaptPromptForChannel(rawSystemPrompt, msg.channelType);

// ── PAIRING GATE (Telegram / WeChat) ──
Expand Down
13 changes: 13 additions & 0 deletions src/system-prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { MoziConfigSchema } from './config/index.js';
import { loadDelegationSystemPrompt, loadSystemPrompt } from './system-prompt.js';
import { setupTestDb, teardownTestDb } from './test-helpers.js';
import { getWorkspaceDir } from './tools/workspace-policy.js';

let dbTmpDir: string;
let workspaceTmpDir: string;
Expand All @@ -20,6 +21,18 @@ afterAll(() => {
});

describe('system prompt assembly', () => {
it('publishes the canonical user workspace instead of the shared config workspace', () => {
const base = MoziConfigSchema.parse({});
const userId = 'prompt-user-uuid';
const prompt = loadSystemPrompt({
...base,
workspace: { ...base.workspace, dir: workspaceTmpDir },
}, 'default', userId);

expect(prompt).toContain(`- workspaceDir: ${getWorkspaceDir(userId)}`);
expect(prompt).not.toContain(`- workspaceDir: ${workspaceTmpDir}`);
});

it('keeps generic user turns focused on capabilities instead of product self-reference', () => {
const base = MoziConfigSchema.parse({});
const prompt = loadSystemPrompt({
Expand Down
13 changes: 9 additions & 4 deletions src/system-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { getAllRegisteredTools } from './tools/dynamic-registry.js';
import { getRuntimeProjectRoot } from './runtime/project-root.js';
import { getDefaultOutputDir } from './paths.js';
import { formatAvailableToolsSection } from './prompt-sections.js';
import { getWorkspaceDir } from './tools/workspace-policy.js';

/**
* Resolve the workspace directory from config, expanding ~ to home.
Expand All @@ -40,8 +41,9 @@ export function resolveTenantId(tenantId?: string): string {
export function loadSystemPrompt(
config: MoziConfig,
tenantId?: string,
userId?: string,
): string {
return loadSystemPromptLayers(config, tenantId, true);
return loadSystemPromptLayers(config, tenantId, true, userId);
}

/**
Expand All @@ -53,16 +55,19 @@ export function loadSystemPrompt(
export function loadDelegationSystemPrompt(
config: MoziConfig,
tenantId?: string,
userId?: string,
): string {
return loadSystemPromptLayers(config, tenantId, false);
return loadSystemPromptLayers(config, tenantId, false, userId);
}

function loadSystemPromptLayers(
config: MoziConfig,
tenantId: string | undefined,
includeUserProfile: boolean,
userId: string | undefined,
): string {
const workspaceDir = resolveWorkspaceDir(config);
const overrideWorkspaceDir = resolveWorkspaceDir(config);
const workspaceDir = getWorkspaceDir(userId);
const outputDir = resolveOutputDir();
const parts: string[] = [];
const runtimeRoot = getRuntimeProjectRoot();
Expand All @@ -81,7 +86,7 @@ function loadSystemPromptLayers(
? ['SOUL.local.md', 'AGENTS.local.md', 'USER.md']
: ['SOUL.local.md', 'AGENTS.local.md'];
for (const file of userOverrideFiles) {
const userPath = join(workspaceDir, file);
const userPath = join(overrideWorkspaceDir, file);
if (existsSync(userPath)) {
parts.push(readFileSync(userPath, 'utf-8'));
}
Expand Down
16 changes: 8 additions & 8 deletions src/tools/fs-root-grants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,13 +98,13 @@ function writeFsConfig(raw: RawConfigObject): void {
loadConfig(getConfigPath());
}

function defaultRootPaths(): string[] {
return [getOutputDir(), getWorkspaceDir()];
function defaultRootPaths(userId?: string): string[] {
return [getOutputDir(), getWorkspaceDir(userId)];
}

function isDefaultRoot(path: string): boolean {
function isDefaultRoot(path: string, userId?: string): boolean {
const resolved = resolveRootPath(path);
return defaultRootPaths().some((root) => sameRoot(resolved, root));
return defaultRootPaths(userId).some((root) => sameRoot(resolved, root));
}

function uniqueRootRecords(records: FsRootRecord[]): FsRootRecord[] {
Expand All @@ -123,7 +123,7 @@ function uniqueRootRecords(records: FsRootRecord[]): FsRootRecord[] {
* List the logical file-access roots known to Node. This is not an OS sandbox;
* Track B adds native security-scoped enforcement underneath this allowlist.
*/
export function listFsRoots(): FsRootRecord[] {
export function listFsRoots(userId?: string): FsRootRecord[] {
const policy = getFsPolicy();
const roots: FsRootRecord[] = [
{
Expand All @@ -135,15 +135,15 @@ export function listFsRoots(): FsRootRecord[] {
},
{
tier: 'workspace',
path: getWorkspaceDir(),
path: getWorkspaceDir(userId),
label: 'Workspace',
granted_at: null,
bookmark: null,
},
];

for (const grant of policy.grantedProjectRoots) {
if (isDefaultRoot(grant.path)) continue;
if (isDefaultRoot(grant.path, userId)) continue;
roots.push({
tier: 'project',
path: grant.path,
Expand All @@ -154,7 +154,7 @@ export function listFsRoots(): FsRootRecord[] {
}

for (const root of policy.additionalAllowedRoots) {
if (isDefaultRoot(root)) continue;
if (isDefaultRoot(root, userId)) continue;
if (roots.some((existing) => sameRoot(existing.path, root))) continue;
roots.push({
tier: 'project',
Expand Down
21 changes: 20 additions & 1 deletion src/tools/fs-tools.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import { readFileSync } from 'node:fs';
import { readFileSync, realpathSync } from 'node:fs';
import { createTempDir, removeTempDir } from '../test-helpers.js';
import type { ArtifactEvent } from '../artifacts/types.js';
import { ArtifactCoordinator } from '../artifacts/coordinator.js';
Expand Down Expand Up @@ -60,6 +60,25 @@ afterAll(() => {
});

describe('tools/fs-tools write_file artifact terminalization', () => {
it('reads back a Full Access write to the configured workspace for a UUID user', async () => {
const context: ToolContext = {
tenantId: 'default',
userId: '08adcd45-6f72-43ed-b08a-b3713209ee54',
agentId: 'test-agent',
permissionLevel: 'L3_FULL_ACCESS',
};
const path = `${tmpDir}/ashare_20260805/env_probe.json`;
const content = '{"is_trading_day":true}';

const written = await executeFsTool('write_file', { path, content }, 'call_write_legacy_workspace', context);
const read = await executeFsTool('read_file', { path }, 'call_read_legacy_workspace', context);

expect(written).toMatchObject({ is_error: false });
expect(read).toMatchObject({ is_error: false, content });
expect(realpathSync(written!.file_path!)).toBe(realpathSync(path));
expect(realpathSync(read!.file_path!)).toBe(realpathSync(path));
});

it('emits a terminal completed patch carrying plugin_id when reusing a pre-opened coordinator id, even for content <= 20 chars', async () => {
const events: ArtifactEvent[] = [];
const toolCallId = 'call_short_reuse';
Expand Down
23 changes: 17 additions & 6 deletions src/tools/fs-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,9 @@ import { buildFileArtifactPreviewFields } from '../artifacts/file-preview.js';
import { ensureArtifactCoordinator } from '../artifacts/coordinator.js';
import { remoteArtifactDependencies, unresolvedArtifactPlaceholders } from '../artifacts/content-contract.js';
import {
resolveReadPath,
resolveWritePath,
resolveWriteRoots,
getReadAllowedPaths,
resolveReadRoots,
ensureToolWorkspaceDir,
runTel,
telErrorMessage,
Expand Down Expand Up @@ -206,8 +205,15 @@ export async function executeFsTool(
return { tool_call_id: id, content: 'Error: "path" parameter is required and must be a string', is_error: true };
}
await ensureToolWorkspaceDir(userId);
const allowedPaths = resolveReadRoots(context);
maybeEnableRepoInspection(context?.repoInspection, path, context?.workspaceRootPath);
const grounding = resolveInspectionReadPath(path, context?.repoInspection, userId, context?.workspaceRootPath);
const grounding = resolveInspectionReadPath(
path,
context?.repoInspection,
userId,
context?.workspaceRootPath,
allowedPaths,
);
if (!grounding.resolvedPath) {
return {
tool_call_id: id,
Expand All @@ -216,7 +222,6 @@ export async function executeFsTool(
};
}
const resolved = grounding.resolvedPath;
const allowedPaths = getReadAllowedPaths(userId, context?.workspaceRootPath);
const telResult = await runTel('filesystem', 'read', {
path: resolved,
...(allowedPaths ? { allowed_paths: allowedPaths } : {}),
Expand Down Expand Up @@ -500,8 +505,15 @@ export async function executeFsTool(
case 'list_directory': {
const path = (args.path as string) || '.';
await ensureToolWorkspaceDir(userId);
const allowedPaths = resolveReadRoots(context);
maybeEnableRepoInspection(context?.repoInspection, path, context?.workspaceRootPath);
const grounding = resolveInspectionDirectoryPath(path, context?.repoInspection, userId, context?.workspaceRootPath);
const grounding = resolveInspectionDirectoryPath(
path,
context?.repoInspection,
userId,
context?.workspaceRootPath,
allowedPaths,
);
if (!grounding.resolvedPath) {
return {
tool_call_id: id,
Expand All @@ -510,7 +522,6 @@ export async function executeFsTool(
};
}
const resolved = grounding.resolvedPath;
const allowedPaths = getReadAllowedPaths(userId, context?.workspaceRootPath);
const telResult = await runTel('filesystem', 'list', {
path: resolved,
...(allowedPaths ? { allowed_paths: allowedPaths } : {}),
Expand Down
Loading
Loading