Skip to content

Commit 99028f9

Browse files
committed
fix: preserve authority boundaries during revert
1 parent bbe2a90 commit 99028f9

5 files changed

Lines changed: 77 additions & 6 deletions

File tree

apps/daemon/src/design-systems/index.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -801,6 +801,10 @@ async function resolveDesignSystemAssetsUncached(
801801
builtInRoot: string,
802802
userInstalledRoot: string,
803803
): Promise<DesignSystemAssets> {
804+
if (designSystemId.startsWith('user:')) {
805+
return readDesignSystemAssets(userInstalledRoot, designSystemId);
806+
}
807+
804808
const builtIn = await readDesignSystemAssets(builtInRoot, designSystemId);
805809
if (builtIn.tokensCss !== undefined && builtIn.fixtureHtml !== undefined) {
806810
return builtIn;
@@ -834,12 +838,15 @@ async function designSystemAssetsCacheFingerprint(
834838
userInstalledRoot: string,
835839
env: NodeJS.ProcessEnv,
836840
): Promise<string> {
841+
const roots = designSystemId.startsWith('user:')
842+
? [designSystemAssetsRootFingerprint(userInstalledRoot, designSystemId)]
843+
: [
844+
designSystemAssetsRootFingerprint(builtInRoot, designSystemId),
845+
designSystemAssetsRootFingerprint(userInstalledRoot, designSystemId),
846+
];
837847
const payload = {
838848
tokenChannel: env.OD_DESIGN_TOKEN_CHANNEL ?? null,
839-
roots: await Promise.all([
840-
designSystemAssetsRootFingerprint(builtInRoot, designSystemId),
841-
designSystemAssetsRootFingerprint(userInstalledRoot, designSystemId),
842-
]),
849+
roots: await Promise.all(roots),
843850
};
844851
return createHash('sha256').update(JSON.stringify(payload), 'utf8').digest('hex');
845852
}

apps/daemon/src/routes/runs.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -794,6 +794,8 @@ function withoutSensitiveRunInput(body: JsonRecord): JsonRecord {
794794
delete sanitized.byokProfileId;
795795
delete sanitized.apiKey;
796796
delete sanitized.rechargeResumeCapability;
797+
// Workspace scope is a server-issued authorization fact, not a request option.
798+
delete sanitized.workspaceScope;
797799
return sanitized;
798800
}
799801

@@ -1364,7 +1366,9 @@ export function registerRunRoutes(app: Express, ctx: RegisterRunRoutesDeps) {
13641366
mediaExecution: mediaExecution.policy,
13651367
toolBundle: toolBundle.bundle,
13661368
...(effectiveAgentId ? { agentId: effectiveAgentId } : {}),
1367-
...(preparedWorkspaceScope ? { workspaceScope: preparedWorkspaceScope } : {}),
1369+
// Always replace any untrusted request field, including with null for an
1370+
// unbound project.
1371+
workspaceScope: preparedWorkspaceScope,
13681372
};
13691373
if (resolvedSnapshot?.ok) {
13701374
meta.appliedPluginSnapshotId = resolvedSnapshot.snapshotId;
@@ -3134,6 +3138,7 @@ export function registerRunRoutes(app: Express, ctx: RegisterRunRoutesDeps) {
31343138
mediaExecution: mediaExecution.policy,
31353139
toolBundle: toolBundle.bundle,
31363140
...(chatProject?.metadata ? { projectMetadata: chatProject.metadata } : {}),
3141+
workspaceScope: null,
31373142
};
31383143
// Mirror the POST /api/runs ownership check: the assistantMessageId must
31393144
// reference an assistant message in THIS conversation, or the run mutates a

apps/daemon/tests/byok/run-input-boundary.test.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,13 +36,20 @@ describe('BYOK run input boundary', () => {
3636
})).toBe(true);
3737
});
3838

39-
it('removes every credential-bearing compatibility field before persistence', () => {
39+
it('removes credential-bearing and server-owned fields before persistence', () => {
4040
const sanitized = __forTestWithoutSensitiveRunInput({
4141
agentId: 'byok-opencode',
4242
byokProfileId: 'byok-openrouter',
4343
byokProvider: { apiKey: 'nested-secret' },
4444
apiKey: 'top-level-secret',
4545
rechargeResumeCapability: 'capability-secret',
46+
workspaceScope: {
47+
schemaVersion: 1,
48+
projectId: 'forged-project',
49+
workspaceId: 'forged-workspace',
50+
workspaceMemberId: 'forged-member',
51+
source: 'persisted_project_binding',
52+
},
4653
message: 'Create a site',
4754
});
4855

apps/daemon/tests/design-systems/assets.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -551,6 +551,28 @@ describe('isDesignTokenChannelEnabled (PR-D env gate)', () => {
551551
// run that whole pipeline (env gate → readDesignSystemAssets per
552552
// root → fallback chain → DesignSystemAssets shape) end-to-end.
553553
describe('resolveDesignSystemAssets (PR-D server-layer asset resolution)', () => {
554+
it('keeps a user-prefixed selection on the user-installed package root', async () => {
555+
clearDesignSystemAssetsCacheForTests();
556+
const builtInRoot = fresh();
557+
const userRoot = fresh();
558+
writeDesignSystemProject(builtInRoot, 'default', {
559+
tokens: ':root { --authority: built-in; }',
560+
components: '<button class="built-in">Built-in fixture</button>',
561+
});
562+
writeDesignSystemProject(userRoot, 'default', {
563+
tokens: ':root { --authority: user; }',
564+
components: '<button class="user-installed">User fixture</button>',
565+
});
566+
567+
const assets = await resolveDesignSystemAssets('user:default', builtInRoot, userRoot, {});
568+
569+
expect(assets.tokensCss).toBe(':root { --authority: user; }');
570+
expect(assets.fixtureHtml).toBe(
571+
'<button class="user-installed">User fixture</button>',
572+
);
573+
clearDesignSystemAssetsCacheForTests();
574+
});
575+
554576
it('returns the built-in assets when the channel is enabled (env unset, default-on)', async () => {
555577
const builtInRoot = fresh();
556578
const userRoot = fresh();

apps/daemon/tests/run-create-workspace-gate.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -815,6 +815,36 @@ describe('POST /api/runs — workspace mutation gate', () => {
815815
expect(typeof payload.runId).toBe('string');
816816
});
817817

818+
it.each(['/api/runs', '/api/chat'])(
819+
'ignores a client-forged workspace scope for an unbound project through %s',
820+
async (route) => {
821+
const baseUrl = await startServer();
822+
const response = await fetch(`${baseUrl}${route}`, {
823+
method: 'POST',
824+
headers: { 'Content-Type': 'application/json' },
825+
body: JSON.stringify({
826+
projectId: UNBOUND_PROJECT,
827+
agentId: 'claude',
828+
message: 'do not trust request scope',
829+
workspaceScope: {
830+
schemaVersion: 1,
831+
projectId: UNBOUND_PROJECT,
832+
workspaceId: 'forged-workspace',
833+
workspaceMemberId: 'forged-member',
834+
source: 'persisted_project_binding',
835+
},
836+
}),
837+
});
838+
839+
expect(response.status).toBe(202);
840+
const { runId } = (await response.json()) as { runId: string };
841+
const statusResponse = await fetch(`${baseUrl}/api/runs/${runId}`);
842+
expect(statusResponse.status).toBe(200);
843+
const run = await statusResponse.json() as Record<string, unknown>;
844+
expect(run.workspaceScope).toBeNull();
845+
},
846+
);
847+
818848
it('keeps an untyped historical AMR run account-scoped during a directory outage', async () => {
819849
const verifyWorkspaceRequestAuthority = vi.fn(async () => ({
820850
ok: false,

0 commit comments

Comments
 (0)