Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3944935cc | ||
|
|
ebbf682374 |
@@ -1,332 +0,0 @@
|
||||
import { type Type } from '@nestjs/common';
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
import type { SlashCommandPayload } from '@mosaicstack/types';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { AgentService, type AgentSession } from '../agent/agent.service.js';
|
||||
import { ProviderService } from '../agent/provider.service.js';
|
||||
import { AppModule } from '../app.module.js';
|
||||
import { CommandAuthorizationService } from '../commands/command-authorization.service.js';
|
||||
import { CommandExecutorService } from '../commands/command-executor.service.js';
|
||||
import { CommandsModule } from '../commands/commands.module.js';
|
||||
import { CommandRuntimeApprovalVerifier } from '../commands/runtime-approval-verifier.js';
|
||||
import { PreferencesModule } from '../preferences/preferences.module.js';
|
||||
import { SystemOverrideService } from '../preferences/system-override.service.js';
|
||||
|
||||
const fakeDb = {
|
||||
$client: { exec: async (): Promise<void> => {} },
|
||||
execute: async (): Promise<{ rows: unknown[] }> => ({ rows: [] }),
|
||||
select: () => ({
|
||||
from: () => ({
|
||||
where: async (): Promise<Array<{ count: number }>> => [{ count: 1 }],
|
||||
}),
|
||||
}),
|
||||
insert: () => ({ values: async (): Promise<void> => {} }),
|
||||
};
|
||||
|
||||
const fakeProviderService = {
|
||||
onModuleInit: async (): Promise<void> => {},
|
||||
onModuleDestroy: (): void => {},
|
||||
getRegistry: () => ({ getAvailable: () => [], getAll: () => [], find: () => undefined }),
|
||||
getDefaultModel: () => undefined,
|
||||
listAvailableModels: () => [],
|
||||
listProviders: () => [],
|
||||
getAdapter: () => undefined,
|
||||
getProvidersHealth: () => [],
|
||||
};
|
||||
|
||||
function compileRealAppGraph(): Promise<TestingModule> {
|
||||
return Test.createTestingModule({ imports: [AppModule] })
|
||||
.overrideProvider('DB_HANDLE')
|
||||
.useValue({ db: fakeDb, close: async (): Promise<void> => {} })
|
||||
.overrideProvider('DB')
|
||||
.useValue(fakeDb)
|
||||
.overrideProvider('STORAGE_ADAPTER')
|
||||
.useValue({
|
||||
name: 'required-security-wiring-test',
|
||||
migrate: async (): Promise<void> => {},
|
||||
close: async (): Promise<void> => {},
|
||||
})
|
||||
.overrideProvider('AUTH')
|
||||
.useValue({})
|
||||
.overrideProvider('BRAIN')
|
||||
.useValue({ conversations: {}, agents: {} })
|
||||
.overrideProvider('LOG_SERVICE')
|
||||
.useValue({})
|
||||
.overrideProvider('MEMORY')
|
||||
.useValue({})
|
||||
.overrideProvider('MEMORY_ADAPTER')
|
||||
.useValue({})
|
||||
.overrideProvider(ProviderService)
|
||||
.useValue(fakeProviderService)
|
||||
.compile();
|
||||
}
|
||||
|
||||
function providerToken(provider: unknown): unknown {
|
||||
return typeof provider === 'function' ? provider : (provider as { provide?: unknown })?.provide;
|
||||
}
|
||||
|
||||
interface MaskingConsumer {
|
||||
moduleType: Type<unknown>;
|
||||
token: Type<unknown>;
|
||||
useValue: object;
|
||||
}
|
||||
|
||||
async function compileWithoutProvider(
|
||||
moduleType: Type<unknown>,
|
||||
missingToken: Type<unknown>,
|
||||
maskingConsumer: MaskingConsumer,
|
||||
): Promise<{ error: unknown; moduleRef: TestingModule | undefined }> {
|
||||
const touchedModules = new Set([moduleType, maskingConsumer.moduleType]);
|
||||
const originals = Array.from(touchedModules, (touchedModule: Type<unknown>) => ({
|
||||
moduleType: touchedModule,
|
||||
providers: (Reflect.getMetadata('providers', touchedModule) ?? []) as unknown[],
|
||||
exports: (Reflect.getMetadata('exports', touchedModule) ?? []) as unknown[],
|
||||
}));
|
||||
|
||||
for (const original of originals) {
|
||||
const providers = original.providers.flatMap((provider: unknown): unknown[] => {
|
||||
const token = providerToken(provider);
|
||||
if (original.moduleType === moduleType && token === missingToken) return [];
|
||||
if (original.moduleType === maskingConsumer.moduleType && token === maskingConsumer.token) {
|
||||
return [{ provide: maskingConsumer.token, useValue: maskingConsumer.useValue }];
|
||||
}
|
||||
return [provider];
|
||||
});
|
||||
const exports = original.exports.filter(
|
||||
(exported: unknown): boolean =>
|
||||
original.moduleType !== moduleType || providerToken(exported) !== missingToken,
|
||||
);
|
||||
Reflect.defineMetadata('providers', providers, original.moduleType);
|
||||
Reflect.defineMetadata('exports', exports, original.moduleType);
|
||||
}
|
||||
|
||||
let moduleRef: TestingModule | undefined;
|
||||
let error: unknown;
|
||||
try {
|
||||
moduleRef = await compileRealAppGraph();
|
||||
} catch (caught: unknown) {
|
||||
error = caught;
|
||||
} finally {
|
||||
for (const original of originals) {
|
||||
Reflect.defineMetadata('providers', original.providers, original.moduleType);
|
||||
Reflect.defineMetadata('exports', original.exports, original.moduleType);
|
||||
}
|
||||
}
|
||||
return { error, moduleRef };
|
||||
}
|
||||
|
||||
async function closeIfCompiled(moduleRef: TestingModule | undefined): Promise<void> {
|
||||
if (moduleRef) await moduleRef.close();
|
||||
}
|
||||
|
||||
describe('required security wiring — real AppModule startup refusal', () => {
|
||||
it('FL-01 positive control: the real graph compiles when CommandAuthorizationService is bound', async () => {
|
||||
const moduleRef = await compileRealAppGraph();
|
||||
try {
|
||||
expect(moduleRef.get(CommandAuthorizationService, { strict: false })).toBeInstanceOf(
|
||||
CommandAuthorizationService,
|
||||
);
|
||||
} finally {
|
||||
await moduleRef.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('FL-01 negative control: absence read as permission is refused at module compilation', async () => {
|
||||
const { error, moduleRef } = await compileWithoutProvider(
|
||||
CommandsModule,
|
||||
CommandAuthorizationService,
|
||||
{
|
||||
moduleType: CommandsModule,
|
||||
token: CommandRuntimeApprovalVerifier,
|
||||
useValue: {},
|
||||
},
|
||||
);
|
||||
await closeIfCompiled(moduleRef);
|
||||
|
||||
expect(
|
||||
error,
|
||||
'absence read as permission: AppModule compilation accepted a missing CommandAuthorizationService binding',
|
||||
).toBeInstanceOf(Error);
|
||||
if (!(error instanceof Error)) return;
|
||||
expect(error.message).toContain('CommandExecutorService');
|
||||
expect(error.message).toContain('CommandAuthorizationService');
|
||||
});
|
||||
|
||||
it('FL-11 positive control: the real graph compiles when SystemOverrideService is bound', async () => {
|
||||
const moduleRef = await compileRealAppGraph();
|
||||
try {
|
||||
expect(moduleRef.get(SystemOverrideService, { strict: false })).toBeInstanceOf(
|
||||
SystemOverrideService,
|
||||
);
|
||||
} finally {
|
||||
await moduleRef.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('FL-11 negative control: absence read as permission is refused at module compilation', async () => {
|
||||
const { error, moduleRef } = await compileWithoutProvider(
|
||||
PreferencesModule,
|
||||
SystemOverrideService,
|
||||
{
|
||||
moduleType: CommandsModule,
|
||||
token: CommandExecutorService,
|
||||
useValue: {},
|
||||
},
|
||||
);
|
||||
await closeIfCompiled(moduleRef);
|
||||
|
||||
expect(
|
||||
error,
|
||||
'absence read as permission: AppModule compilation accepted a missing SystemOverrideService binding',
|
||||
).toBeInstanceOf(Error);
|
||||
if (!(error instanceof Error)) return;
|
||||
expect(error.message).toContain('AgentService');
|
||||
expect(error.message).toContain('SystemOverrideService');
|
||||
});
|
||||
});
|
||||
|
||||
const actorScope = { userId: 'security-user', tenantId: 'security-tenant' };
|
||||
const conversationId = 'security-conversation';
|
||||
|
||||
function directExecutorWithoutAuthorization(systemOverrideSet: ReturnType<typeof vi.fn>) {
|
||||
const registry = {
|
||||
getManifest: vi.fn(() => ({
|
||||
version: 1,
|
||||
commands: [
|
||||
{
|
||||
name: 'system',
|
||||
aliases: [],
|
||||
description: 'Set instruction authority',
|
||||
scope: 'agent' as const,
|
||||
execution: 'socket' as const,
|
||||
available: true,
|
||||
},
|
||||
],
|
||||
skills: [],
|
||||
})),
|
||||
};
|
||||
return new CommandExecutorService(
|
||||
registry as never,
|
||||
{ getSession: vi.fn() } as never,
|
||||
{ set: systemOverrideSet, clear: vi.fn() } as never,
|
||||
{ collect: vi.fn() } as never,
|
||||
null,
|
||||
{ agents: {} } as never,
|
||||
null,
|
||||
null,
|
||||
{ getServerStatuses: vi.fn(() => []), getToolDefinitions: vi.fn(() => []) } as never,
|
||||
undefined as never,
|
||||
);
|
||||
}
|
||||
|
||||
function directAgentWithoutSystemOverride(piPrompt: ReturnType<typeof vi.fn>): {
|
||||
service: AgentService;
|
||||
session: AgentSession;
|
||||
} {
|
||||
const service = new AgentService(
|
||||
{
|
||||
getDefaultModel: vi.fn(() => null),
|
||||
getRegistry: vi.fn(() => ({})),
|
||||
findModel: vi.fn(),
|
||||
listAvailableModels: vi.fn(() => []),
|
||||
} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{ available: false } as never,
|
||||
{} as never,
|
||||
{ getToolDefinitions: vi.fn(() => []) } as never,
|
||||
{ loadForSession: vi.fn(async () => ({ metaTools: [], promptAdditions: [] })) } as never,
|
||||
undefined as never,
|
||||
null,
|
||||
{ collect: vi.fn().mockResolvedValue(undefined) } as never,
|
||||
null,
|
||||
);
|
||||
const session = {
|
||||
id: conversationId,
|
||||
provider: 'test-provider',
|
||||
modelId: 'test-model',
|
||||
piSession: { prompt: piPrompt },
|
||||
listeners: new Set(),
|
||||
unsubscribe: vi.fn(),
|
||||
createdAt: Date.now(),
|
||||
promptCount: 0,
|
||||
channels: new Set(),
|
||||
skillPromptAdditions: [],
|
||||
sandboxDir: process.cwd(),
|
||||
allowedTools: null,
|
||||
userId: actorScope.userId,
|
||||
tenantId: actorScope.tenantId,
|
||||
metrics: {
|
||||
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
modelSwitches: 0,
|
||||
messageCount: 0,
|
||||
lastActivityAt: new Date(0).toISOString(),
|
||||
},
|
||||
} as unknown as AgentSession;
|
||||
const internals = service as unknown as { sessions: Map<string, AgentSession> };
|
||||
internals.sessions.set(conversationId, session);
|
||||
return { service, session };
|
||||
}
|
||||
|
||||
describe('required security wiring — malformed direct absence has zero effects', () => {
|
||||
it('FL-01 refuses command execution before any command effect when authorization is absent', async () => {
|
||||
const systemOverrideSet = vi.fn().mockResolvedValue(undefined);
|
||||
const executor = directExecutorWithoutAuthorization(systemOverrideSet);
|
||||
const payload: SlashCommandPayload = {
|
||||
command: 'system',
|
||||
args: 'authority that must not be stored',
|
||||
conversationId,
|
||||
};
|
||||
let error: unknown;
|
||||
|
||||
try {
|
||||
await executor.execute(payload, actorScope);
|
||||
} catch (caught: unknown) {
|
||||
error = caught;
|
||||
}
|
||||
|
||||
expect
|
||||
.soft(
|
||||
error,
|
||||
'absence read as permission: direct executor accepted missing command authorization',
|
||||
)
|
||||
.toBeInstanceOf(Error);
|
||||
expect
|
||||
.soft(
|
||||
systemOverrideSet,
|
||||
'absence read as permission: command effect occurred without command authorization',
|
||||
)
|
||||
.not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FL-11 refuses prompt execution before any provider or session effect when system override authority is absent', async () => {
|
||||
const piPrompt = vi.fn().mockResolvedValue(undefined);
|
||||
const { service, session } = directAgentWithoutSystemOverride(piPrompt);
|
||||
let error: unknown;
|
||||
|
||||
try {
|
||||
await service.prompt(conversationId, 'must not reach provider', actorScope);
|
||||
} catch (caught: unknown) {
|
||||
error = caught;
|
||||
}
|
||||
|
||||
expect
|
||||
.soft(
|
||||
error,
|
||||
'absence read as permission: direct session accepted missing system override authority',
|
||||
)
|
||||
.toBeInstanceOf(Error);
|
||||
expect
|
||||
.soft(
|
||||
piPrompt,
|
||||
'absence read as permission: provider prompt occurred without system override authority',
|
||||
)
|
||||
.not.toHaveBeenCalled();
|
||||
expect
|
||||
.soft(
|
||||
session.promptCount,
|
||||
'absence read as permission: session state changed without system override authority',
|
||||
)
|
||||
.toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -26,7 +26,7 @@ function makeService(operatorMemory: unknown = null): AgentService {
|
||||
{} as never,
|
||||
{ getToolDefinitions: vi.fn(() => []) } as never,
|
||||
{ loadForSession: vi.fn(async () => ({ metaTools: [], promptAdditions: [] })) } as never,
|
||||
{ get: vi.fn().mockResolvedValue(null), renew: vi.fn().mockResolvedValue(undefined) } as never,
|
||||
null,
|
||||
null,
|
||||
{ collect: vi.fn().mockResolvedValue(undefined) } as never,
|
||||
operatorMemory as never,
|
||||
|
||||
@@ -132,8 +132,9 @@ export class AgentService implements OnModuleDestroy {
|
||||
@Inject(CoordService) private readonly coordService: CoordService,
|
||||
@Inject(McpClientService) private readonly mcpClientService: McpClientService,
|
||||
@Inject(SkillLoaderService) private readonly skillLoaderService: SkillLoaderService,
|
||||
@Optional()
|
||||
@Inject(SystemOverrideService)
|
||||
private readonly systemOverride: SystemOverrideService,
|
||||
private readonly systemOverride: SystemOverrideService | null,
|
||||
@Optional()
|
||||
@Inject(PreferencesService)
|
||||
private readonly preferencesService: PreferencesService | null,
|
||||
@@ -708,22 +709,23 @@ export class AgentService implements OnModuleDestroy {
|
||||
throw new Error(`No agent session found: ${sessionId}`);
|
||||
}
|
||||
this.assertSessionScope(session, scope);
|
||||
session.promptCount += 1;
|
||||
|
||||
// Channel attachments are untrusted URI references. Preserve exact,
|
||||
// authenticated metadata for the agent without treating it as authority.
|
||||
const attachmentContext = this.attachmentContext(attachments);
|
||||
|
||||
// Prepend session-scoped system override if present (renew TTL on each turn).
|
||||
// Required instruction-authority wiring is consulted before session/provider effects.
|
||||
// Prepend session-scoped system override if present (renew TTL on each turn)
|
||||
let effectiveMessage = `${message}${attachmentContext}`;
|
||||
const override = await this.systemOverride.get(sessionId, scope);
|
||||
if (override) {
|
||||
effectiveMessage = `[System Override]\n${override}\n\n${effectiveMessage}`;
|
||||
await this.systemOverride.renew(sessionId, scope);
|
||||
this.logger.debug(`Applied system override for session ${sessionId}`);
|
||||
if (this.systemOverride) {
|
||||
const override = await this.systemOverride.get(sessionId, scope);
|
||||
if (override) {
|
||||
effectiveMessage = `[System Override]\n${override}\n\n${effectiveMessage}`;
|
||||
await this.systemOverride.renew(sessionId, scope);
|
||||
this.logger.debug(`Applied system override for session ${sessionId}`);
|
||||
}
|
||||
}
|
||||
|
||||
session.promptCount += 1;
|
||||
try {
|
||||
await session.piSession.prompt(effectiveMessage);
|
||||
} catch (err) {
|
||||
|
||||
@@ -80,10 +80,6 @@ const mockMcpClient = {
|
||||
getToolDefinitions: vi.fn(() => []),
|
||||
};
|
||||
|
||||
const allowAuthorization = {
|
||||
authorize: vi.fn().mockResolvedValue({ allowed: true }),
|
||||
};
|
||||
|
||||
function buildService(
|
||||
redis: typeof mockRedis | null = mockRedis,
|
||||
mcpClient: {
|
||||
@@ -102,7 +98,6 @@ function buildService(
|
||||
null,
|
||||
mockChatGateway as never,
|
||||
mcpClient as never,
|
||||
allowAuthorization as never,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -35,8 +35,9 @@ export class CommandExecutorService {
|
||||
@Inject(forwardRef(() => ChatGateway))
|
||||
private readonly chatGateway: ChatGateway | null,
|
||||
@Inject(McpClientService) private readonly mcpClient: McpClientService,
|
||||
@Optional()
|
||||
@Inject(CommandAuthorizationService)
|
||||
private readonly authorization: CommandAuthorizationService,
|
||||
private readonly authorization: CommandAuthorizationService | null = null,
|
||||
) {}
|
||||
|
||||
async execute(
|
||||
@@ -56,13 +57,13 @@ export class CommandExecutorService {
|
||||
};
|
||||
}
|
||||
|
||||
const authorization = await this.authorization.authorize(
|
||||
const authorization = await this.authorization?.authorize(
|
||||
def,
|
||||
payload,
|
||||
userId,
|
||||
payload.approvalId,
|
||||
);
|
||||
if (!authorization.allowed) {
|
||||
if (authorization && !authorization.allowed) {
|
||||
return { command, conversationId, success: false, message: authorization.reason };
|
||||
}
|
||||
|
||||
@@ -170,7 +171,7 @@ export class CommandExecutorService {
|
||||
const def = this.registry
|
||||
.getManifest()
|
||||
.commands.find((command) => command.name === payload.command);
|
||||
if (!def) return null;
|
||||
if (!def || !this.authorization) return null;
|
||||
return this.authorization.createApproval(def, payload, scope.userId);
|
||||
}
|
||||
|
||||
|
||||
@@ -55,10 +55,6 @@ const mockMcpClient = {
|
||||
reconnectServer: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const allowAuthorization = {
|
||||
authorize: vi.fn().mockResolvedValue({ allowed: true }),
|
||||
};
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function buildRegistry(): CommandRegistryService {
|
||||
@@ -78,7 +74,6 @@ function buildExecutor(registry: CommandRegistryService): CommandExecutorService
|
||||
null, // reloadService (optional)
|
||||
null, // chatGateway (optional)
|
||||
mockMcpClient as never,
|
||||
allowAuthorization as never,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -159,7 +159,6 @@ describe('ReloadService — /reload command sanitizes plugin errors', () => {
|
||||
reloadService,
|
||||
mockChatGateway as never,
|
||||
mockMcpClient as never,
|
||||
{ authorize: vi.fn().mockResolvedValue({ allowed: true }) } as never,
|
||||
);
|
||||
|
||||
const payload: SlashCommandPayload = { command: 'reload', conversationId: 'conv-1' };
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
# #1179 — Required security DI wiring
|
||||
|
||||
## Objective
|
||||
|
||||
Eliminate the shared fail-open defect class **absence read as permission**:
|
||||
|
||||
- FL-01: missing `CommandAuthorizationService` must refuse Nest startup and must not permit command effects.
|
||||
- FL-11: missing `SystemOverrideService` must refuse Nest startup and must not omit stored instruction authority while allowing provider/session effects.
|
||||
|
||||
## Tracking
|
||||
|
||||
- Issue: #1179, child of #1156
|
||||
- Branch: `fix/1179-required-security-di`
|
||||
- Base: `origin/next` at `216cd72226cd9ee17eea461cfe7cd0e010a22f02`
|
||||
|
||||
## Plan
|
||||
|
||||
1. RED: compile the real `AppModule` graph with each required provider independently removed, with a positive control for each intact binding.
|
||||
2. RED: directly exercise each malformed absence path and assert zero command/provider/session effects.
|
||||
3. Stop and report RED to the coordinator before production implementation.
|
||||
4. After authorization, make both constructor injections required, remove absence-as-permission branches, and update explicit legitimate optional test seams.
|
||||
5. Run focused Gateway tests, typecheck, lint, format, build, independent exact-head verification, and focused security review.
|
||||
|
||||
## Immutable path fence
|
||||
|
||||
Production changes are confined to:
|
||||
|
||||
- `apps/gateway/src/commands/command-executor.service.ts`
|
||||
- `apps/gateway/src/agent/agent.service.ts`
|
||||
|
||||
Tests and task evidence are confined to:
|
||||
|
||||
- `apps/gateway/src/__tests__/required-security-wiring.test.ts`
|
||||
- existing direct-constructor specs that require explicit required arguments
|
||||
- `docs/scratchpads/1179-required-security-di.md`
|
||||
|
||||
No files in #1178, #1072, #1080, or #1054 lanes are in scope. `docs/TASKS.md` is orchestrator-owned and will not be modified.
|
||||
|
||||
## Budget
|
||||
|
||||
No explicit token ceiling was provided. Working assumption: one narrow Gateway security packet; split and stop if either arm requires unrelated module rewiring.
|
||||
|
||||
## Progress
|
||||
|
||||
- Intake read from #1179 and parent #1156.
|
||||
- Base independently resolved from the issue's pre-native-stage ordering and repository `origin/next` ref; branch HEAD verified byte-for-byte against the remote ref.
|
||||
- Real consumers and direct constructors inventoried.
|
||||
|
||||
## Tests
|
||||
|
||||
### RED
|
||||
|
||||
- `required-security-wiring.test.ts`: 4 failed, 2 passed before implementation.
|
||||
- Both real-graph negative controls showed module compilation accepted the missing target binding.
|
||||
- Direct FL-01 showed one unauthorized command effect; direct FL-11 showed one provider prompt and one session counter mutation.
|
||||
|
||||
### GREEN
|
||||
|
||||
- `required-security-wiring.test.ts`: 6/6 passed.
|
||||
- FL-01-only production revert: exactly the two FL-01 test cases failed; all four other cases, including FL-11, passed.
|
||||
- FL-11-only production revert: exactly the two FL-11 test cases failed; all four other cases, including FL-01, passed.
|
||||
- Full Gateway suite: 74 files passed, 7 skipped; 831 tests passed, 17 skipped.
|
||||
- Gateway typecheck: passed.
|
||||
- Gateway lint: passed.
|
||||
- Gateway build: passed.
|
||||
- Changed-file Prettier check: passed.
|
||||
|
||||
### Review
|
||||
|
||||
- Codex code review: APPROVE, 0 findings.
|
||||
- Codex focused security review: risk `none`, 0 findings.
|
||||
- Independent exact-head review remains assigned to Scrappy through the coordinator.
|
||||
|
||||
## Risks / blockers
|
||||
|
||||
- `AgentModule` / `CommandsModule` / `ChatModule` contain a production cycle; the module test therefore uses the real top-level `AppModule` and replaces only storage/network leaves, preserving the target service in each arm while isolating the separate required consumer that would otherwise mask that arm's defect.
|
||||
- No broad module rewrite was required.
|
||||
@@ -292,16 +292,6 @@ esac
|
||||
_build_runtime_bin_prefix() {
|
||||
local candidates=()
|
||||
if [ -n "$MOSAIC_RUNTIME_BIN" ]; then candidates+=("$MOSAIC_RUNTIME_BIN"); fi
|
||||
# A host with no system Node gets one bootstrapped here by tools/install.sh, which
|
||||
# records it in ~/.profile. The fleet unit runs `env -i ... bash --noprofile --norc`
|
||||
# by design, so ~/.profile is never read and the directory has to be named here.
|
||||
# The npm probe below cannot cover this: it reports a package prefix
|
||||
# (~/.npm-global), never a Node runtime directory. It sits ahead of the npm probe so
|
||||
# the bootstrapped runtime wins on a host that has both — that is the one the installer
|
||||
# verified — while an explicit MOSAIC_RUNTIME_BIN still outranks it.
|
||||
# Runtime binaries are `#!/usr/bin/env node`, so without this the pane resolves the
|
||||
# binary and then dies on `env: 'node': No such file or directory`.
|
||||
candidates+=("$PANE_HOME/.mosaic/node/current/bin")
|
||||
if command -v npm >/dev/null 2>&1; then
|
||||
local npm_prefix
|
||||
npm_prefix=$(npm config get prefix 2>/dev/null) || true
|
||||
|
||||
@@ -520,93 +520,6 @@ for blocked in LD_PRELOAD= BASH_ENV= MOSAIC_UNTRUSTED_SENTINEL=; do
|
||||
contains_literal "$pane_environment" "$blocked" && fail "runtime pane received $blocked"
|
||||
done
|
||||
|
||||
# #1256. On a host with no system Node, tools/install.sh bootstraps one into
|
||||
# ~/.mosaic/node/ and writes that directory to ~/.profile. The fleet unit runs
|
||||
# `env -i ... bash --noprofile --norc`, so ~/.profile is never read — correctly, by
|
||||
# design — and _build_runtime_bin_prefix does not list the bootstrap directory. Its
|
||||
# `npm config get prefix` branch cannot cover the gap either: the installer points
|
||||
# npm's prefix at ~/.npm-global, so that branch contributes the npm-global directory
|
||||
# and never the Node one, however it resolves.
|
||||
#
|
||||
# The property under test is not "the string is in PATH". It is that the pane can
|
||||
# EXECUTE a Node-shebang runtime binary — which is what `mosaic` is
|
||||
# (`#!/usr/bin/env node`) and what actually failed: measured on a greenfield VM as
|
||||
# `env: 'node': No such file or directory` after a clean install that reported success.
|
||||
#
|
||||
# So this case runs the pane for real and requires it to have run. A PATH-substring
|
||||
# assertion would pass on a fix that put the directory in the wrong position, and it
|
||||
# would keep passing if the pane later stopped running for some unrelated reason.
|
||||
: > "$TMUX_CALLS"
|
||||
HOME_NODE="$ROOT/bootstrap-node/.config/mosaic"
|
||||
write_generated "$HOME_NODE" "coder-node"
|
||||
NODE_PANE_HOME="${HOME_NODE%/.config/mosaic}"
|
||||
NODE_BOOTSTRAP_BIN="$NODE_PANE_HOME/.mosaic/node/current/bin"
|
||||
mkdir -p "$NODE_BOOTSTRAP_BIN"
|
||||
|
||||
# The bootstrapped runtime. It records that it ran, which is the evidence this case
|
||||
# turns on: no node reachable from the pane means no marker.
|
||||
cat > "$NODE_BOOTSTRAP_BIN/node" <<'SHIM'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
env -0 > "${MOSAIC_HOME:?}/fleet/pane-environment"
|
||||
SHIM
|
||||
chmod +x "$NODE_BOOTSTRAP_BIN/node"
|
||||
|
||||
# write_generated plants its symlinks under the MOSAIC_HOME it is given; here the
|
||||
# pane's HOME is the trusted parent, so the pane's view of "installed" is this
|
||||
# directory instead. `pi` is what #1241 resolves against PANE_PATH; `mosaic` is what
|
||||
# the pane then executes, and it is a Node script — not a bash script that would run
|
||||
# anywhere and quietly hide the defect.
|
||||
mkdir -p "$NODE_PANE_HOME/.npm-global/bin"
|
||||
ln -sf "$FAKE_BIN/pi" "$NODE_PANE_HOME/.npm-global/bin/pi"
|
||||
printf '#!/usr/bin/env node\n' > "$NODE_PANE_HOME/.npm-global/bin/mosaic"
|
||||
chmod +x "$NODE_PANE_HOME/.npm-global/bin/mosaic"
|
||||
|
||||
# The npm branch is modelled ALIVE and still cannot close the gap, which is the
|
||||
# stronger statement. An earlier draft of this case tried to model npm as absent —
|
||||
# true on a real bootstrap host, where npm lives only in the Node directory — and it
|
||||
# refused to run anywhere npm is in the system path, i.e. most machines. It was also
|
||||
# the weaker claim: it would have proven only that a dead branch supplies nothing.
|
||||
#
|
||||
# On a bootstrap host the installer sets npm's prefix to ~/.npm-global. So even with
|
||||
# `command -v npm` true and the branch executing, `npm config get prefix` yields the
|
||||
# npm-global directory and never the Node one. The gap does not depend on whether
|
||||
# that branch runs.
|
||||
NODE_LAUNCHER_BIN="$ROOT/bootstrap-node-launcher-bin"
|
||||
mkdir -p "$NODE_LAUNCHER_BIN"
|
||||
ln -sf "$FAKE_BIN/tmux" "$NODE_LAUNCHER_BIN/tmux"
|
||||
ln -sf "$FAKE_BIN/npm" "$NODE_LAUNCHER_BIN/npm"
|
||||
|
||||
/usr/bin/env -i \
|
||||
"HOME=$NODE_PANE_HOME" \
|
||||
"PATH=$NODE_LAUNCHER_BIN:/usr/bin:/bin" \
|
||||
"MOSAIC_HOME=$HOME_NODE" \
|
||||
"MOSAIC_TEST_TMUX_CALLS=$TMUX_CALLS" \
|
||||
"MOSAIC_TEST_HOME=$NODE_PANE_HOME" \
|
||||
"MOSAIC_TEST_NPM_PREFIX=$NODE_PANE_HOME/.npm-global" \
|
||||
MOSAIC_TEST_FLEET_OWNER=123e4567-e89b-12d3-a456-426614174000 \
|
||||
MOSAIC_TEST_EXECUTE_PANE=1 \
|
||||
"MOSAIC_TEST_PANE_PID=$$" \
|
||||
"$START" coder-node
|
||||
|
||||
[ -f "$HOME_NODE/fleet/pane-environment" ] || \
|
||||
fail "pane could not execute a Node-shebang runtime: $NODE_BOOTSTRAP_BIN is absent from PANE_PATH (#1256)"
|
||||
node_pane_environment=$(tr '\0' '\n' < "$HOME_NODE/fleet/pane-environment")
|
||||
# Colon-pad and match a whole element. A regex with `(^|:)` after `.*` looks like it
|
||||
# does this and does not: an anchor cannot match mid-pattern, so it silently requires
|
||||
# a leading colon and rejects the directory in FIRST position — which is where THIS
|
||||
# FIXTURE puts it: it runs under `env -i` with no MOSAIC_RUNTIME_BIN, so the bootstrap
|
||||
# directory leads. That is a property of the fixture, not of the fix — in general the
|
||||
# directory sits second, after MOSAIC_RUNTIME_BIN. The colon padding makes the
|
||||
# assertion position-independent either way, which is why it is written this way and
|
||||
# not with an anchor. That produced a failure reading "pane ran but PANE_PATH does not
|
||||
# carry <dir>" against a PATH whose first element was that dir.
|
||||
node_pane_path=":$(printf '%s\n' "$node_pane_environment" | sed -n 's/^PATH=//p' | head -1):"
|
||||
case "$node_pane_path" in
|
||||
*":$NODE_BOOTSTRAP_BIN:"*) ;;
|
||||
*) fail "pane ran but PANE_PATH does not carry $NODE_BOOTSTRAP_BIN (PATH=$node_pane_path)" ;;
|
||||
esac
|
||||
|
||||
write_interaction_generated() {
|
||||
local home="$1"
|
||||
local agent="$2"
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* setupPath profile management (issue #1327, MOSAIC-IMPROVEMENTS 4c / D25).
|
||||
*
|
||||
* The profile append used to be guarded on the binDir value it was about to
|
||||
* write, which is blind to accumulation across different Mosaic homes: every
|
||||
* wizard run against a fresh temp home appended a permanent block to the
|
||||
* operator's real shell profile (1,061 measured appends on sb-it-1-dt).
|
||||
*
|
||||
* Arms below map to the requirements:
|
||||
* S1 sentinel-managed block, rewritten in place
|
||||
* S2 a non-default target home never touches the operator profile
|
||||
* S3 byte-identical profile across repeated runs
|
||||
* S4 legacy unmarked `# Mosaic` blocks collapse into the managed block
|
||||
* S5 the Windows ($env:Path) arm shares the same block logic
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir, homedir } from 'node:os';
|
||||
|
||||
let profilePathMock: string | null = null;
|
||||
|
||||
vi.mock('../platform/detect.js', () => ({
|
||||
getShellProfilePath: (): string | null => profilePathMock,
|
||||
}));
|
||||
|
||||
import { setupPath, managedBlockFor, stripLegacyPathBlocks } from './finalize.js';
|
||||
|
||||
// The real resolved default on this host. Tests use it as the comparator a
|
||||
// non-default home must fail against, exactly as the wizard would.
|
||||
const REAL_DEFAULT_HOME = join(homedir(), '.config', 'mosaic');
|
||||
|
||||
function tempHome(prefix: string): string {
|
||||
const dir = join(tmpdir(), prefix);
|
||||
mkdirSync(join(dir, 'bin'), { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
describe('setupPath profile management (#1327)', () => {
|
||||
let workDir: string;
|
||||
let profileFile: string;
|
||||
let defaultLikeHome: string;
|
||||
let otherHome: string;
|
||||
const baseline = '# existing operator content\nexport EDITOR=vim\n';
|
||||
|
||||
beforeEach(() => {
|
||||
workDir = mkdtempSync(join(tmpdir(), 'setuppath-spec-'));
|
||||
profileFile = join(workDir, '.bashrc');
|
||||
writeFileSync(profileFile, baseline, 'utf-8');
|
||||
profilePathMock = profileFile;
|
||||
defaultLikeHome = tempHome(join(workDir, 'home-a', '.config', 'mosaic'));
|
||||
otherHome = tempHome(join(workDir, 'home-b', '.config', 'mosaic'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
profilePathMock = null;
|
||||
rmSync(workDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// S2 — the arm that MUST fail against the pre-fix code: a home that is not
|
||||
// the resolved default may not modify the operator profile at all.
|
||||
it('does not touch the operator profile when the target home is not the resolved default', () => {
|
||||
const action = setupPath(otherHome, REAL_DEFAULT_HOME);
|
||||
expect(action).toBe('skipped');
|
||||
expect(readFileSync(profileFile, 'utf-8')).toBe(baseline);
|
||||
});
|
||||
|
||||
it('returns skipped when no shell profile can be resolved', () => {
|
||||
profilePathMock = null;
|
||||
const action = setupPath(defaultLikeHome, defaultLikeHome);
|
||||
expect(action).toBe('skipped');
|
||||
});
|
||||
|
||||
// S1 + S3 — two distinct homes (each run as the resolved default in turn,
|
||||
// the shape of two legitimate installs against one operator profile) and
|
||||
// repeated runs against the same home both leave exactly one block.
|
||||
it('leaves exactly one managed block after runs against two distinct homes', () => {
|
||||
const first = setupPath(defaultLikeHome, defaultLikeHome);
|
||||
expect(first).toBe('added');
|
||||
|
||||
const second = setupPath(otherHome, otherHome);
|
||||
expect(second).toBe('added');
|
||||
|
||||
const content = readFileSync(profileFile, 'utf-8');
|
||||
const beginCount = content.split('# >>> mosaic begin >>>').length - 1;
|
||||
const endCount = content.split('# <<< mosaic end <<<').length - 1;
|
||||
expect(beginCount).toBe(1);
|
||||
expect(endCount).toBe(1);
|
||||
expect(content).toContain(join(otherHome, 'bin'));
|
||||
expect(content).toContain(baseline);
|
||||
});
|
||||
|
||||
it('is byte-identical across repeated runs against the same home', () => {
|
||||
setupPath(defaultLikeHome, defaultLikeHome);
|
||||
const afterFirst = readFileSync(profileFile, 'utf-8');
|
||||
|
||||
const again = setupPath(defaultLikeHome, defaultLikeHome);
|
||||
expect(again).toBe('already');
|
||||
expect(readFileSync(profileFile, 'utf-8')).toBe(afterFirst);
|
||||
});
|
||||
|
||||
// S4 — pre-existing unmarked blocks from the old append logic collapse
|
||||
// into the single managed block instead of accumulating beside it.
|
||||
it('collapses legacy unmarked # Mosaic blocks into the managed block', () => {
|
||||
const legacy =
|
||||
'# existing operator content\n' +
|
||||
'# Mosaic\n' +
|
||||
'export PATH="/tmp/mosaic-dead-wizard-1/bin:$PATH"\n' +
|
||||
'export EDITOR=vim\n' +
|
||||
'# Mosaic\n' +
|
||||
'export PATH="/tmp/mosaic-dead-wizard-2/bin:$PATH"\n';
|
||||
writeFileSync(profileFile, legacy, 'utf-8');
|
||||
|
||||
const action = setupPath(defaultLikeHome, defaultLikeHome);
|
||||
expect(action).toBe('added');
|
||||
|
||||
const content = readFileSync(profileFile, 'utf-8');
|
||||
expect(content).not.toContain('/tmp/mosaic-dead-wizard-1/bin');
|
||||
expect(content).not.toContain('/tmp/mosaic-dead-wizard-2/bin');
|
||||
expect(content).toContain('export EDITOR=vim');
|
||||
expect(content.split('# >>> mosaic begin >>>').length - 1).toBe(1);
|
||||
expect(content).toContain(join(defaultLikeHome, 'bin'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('managed block helpers (#1327)', () => {
|
||||
// S5 — the Windows arm shares markers and shape with the POSIX arm.
|
||||
it('builds the $env:Path variant inside the same markers', () => {
|
||||
const block = managedBlockFor('C:\\Users\\op\\.config\\mosaic\\bin', true);
|
||||
expect(block).toContain('# >>> mosaic begin >>>');
|
||||
expect(block).toContain('# <<< mosaic end <<<');
|
||||
expect(block).toContain('$env:Path = "C:\\Users\\op\\.config\\mosaic\\bin;$env:Path"');
|
||||
});
|
||||
|
||||
it('builds the POSIX export variant inside the same markers', () => {
|
||||
const block = managedBlockFor('/home/op/.config/mosaic/bin', false);
|
||||
expect(block).toContain('# >>> mosaic begin >>>');
|
||||
expect(block).toContain('export PATH="/home/op/.config/mosaic/bin:$PATH"');
|
||||
expect(block).toContain('# <<< mosaic end <<<');
|
||||
});
|
||||
|
||||
it('strips legacy $env:Path pairs on the Windows arm', () => {
|
||||
const legacy =
|
||||
'# Mosaic\n$env:Path = "C:\\tmp\\dead\\bin;$env:Path"\n' +
|
||||
'# Mosaic\n$env:Path = "C:\\tmp\\dead2\\bin;$env:Path"\n' +
|
||||
'Write-Host hi\n';
|
||||
const stripped = stripLegacyPathBlocks(legacy, true);
|
||||
expect(stripped).not.toContain('C:\\tmp\\dead');
|
||||
expect(stripped).toContain('Write-Host hi');
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,12 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { existsSync, readFileSync, appendFileSync } from 'node:fs';
|
||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { platform } from 'node:os';
|
||||
import type { WizardPrompter } from '../prompter/interface.js';
|
||||
import type { ConfigService } from '../config/config-service.js';
|
||||
import type { WizardState } from '../types.js';
|
||||
import { getShellProfilePath } from '../platform/detect.js';
|
||||
import { DEFAULT_MOSAIC_HOME } from '../constants.js';
|
||||
import { ManifestError } from '../framework/manifest.js';
|
||||
import {
|
||||
getDefaultSkillPaths,
|
||||
@@ -144,32 +145,87 @@ function runDoctor(mosaicHome: string): DoctorResult {
|
||||
|
||||
type PathAction = 'already' | 'added' | 'skipped';
|
||||
|
||||
function setupPath(mosaicHome: string, _p: WizardPrompter): PathAction {
|
||||
const binDir = join(mosaicHome, 'bin');
|
||||
const currentPath = process.env['PATH'] ?? '';
|
||||
const PATH_BLOCK_BEGIN = '# >>> mosaic begin >>>';
|
||||
const PATH_BLOCK_END = '# <<< mosaic end <<<';
|
||||
const PATH_BLOCK_NOTE = '# Managed by the Mosaic installer; this block is rewritten on install.';
|
||||
|
||||
if (currentPath.includes(binDir)) {
|
||||
return 'already';
|
||||
/**
|
||||
* The managed PATH block written into the operator's shell profile.
|
||||
*
|
||||
* The block is delimited by begin/end sentinels so any number of installs,
|
||||
* against any homes, collapse to exactly one block: the writer replaces the
|
||||
* region between the sentinels instead of appending a second copy (#1327).
|
||||
*/
|
||||
export function managedBlockFor(binDir: string, isWindows: boolean): string {
|
||||
const exportLine = isWindows
|
||||
? `$env:Path = "${binDir};$env:Path"`
|
||||
: `export PATH="${binDir}:$PATH"`;
|
||||
return `${PATH_BLOCK_BEGIN}\n${PATH_BLOCK_NOTE}\n${exportLine}\n${PATH_BLOCK_END}\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove legacy unmarked `# Mosaic` PATH pairs appended by pre-#1327
|
||||
* installs. Only the exact two-line shape this installer used to write is
|
||||
* removed; any other `# Mosaic` comment line is left alone.
|
||||
*/
|
||||
export function stripLegacyPathBlocks(content: string, isWindows: boolean): string {
|
||||
const legacyExport = isWindows ? /^\$env:Path = ".*;\$env:Path"$/ : /^export PATH=".*:\$PATH"$/;
|
||||
const lines = content.split('\n');
|
||||
const kept: string[] = [];
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i] ?? '';
|
||||
const next = i + 1 < lines.length ? lines[i + 1] : undefined;
|
||||
if (line === '# Mosaic' && next !== undefined && legacyExport.test(next)) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
kept.push(line);
|
||||
}
|
||||
return kept.join('\n');
|
||||
}
|
||||
|
||||
/** Drop the region between the managed-block sentinels, first occurrence. */
|
||||
function withoutManagedBlock(content: string): string {
|
||||
const beginIdx = content.indexOf(PATH_BLOCK_BEGIN);
|
||||
if (beginIdx < 0) return content;
|
||||
const endIdx = content.indexOf(PATH_BLOCK_END, beginIdx);
|
||||
if (endIdx < 0) return content;
|
||||
return content.slice(0, beginIdx) + content.slice(endIdx + PATH_BLOCK_END.length);
|
||||
}
|
||||
|
||||
export function setupPath(mosaicHome: string, resolvedDefaultHome: string): PathAction {
|
||||
// Never write outside the home under test (#1327 S2): a wizard run against
|
||||
// a non-default home (test harnesses, throwaway installs) must not mutate
|
||||
// the operator's real shell profile.
|
||||
if (mosaicHome !== resolvedDefaultHome) {
|
||||
return 'skipped';
|
||||
}
|
||||
|
||||
const binDir = join(mosaicHome, 'bin');
|
||||
const profilePath = getShellProfilePath();
|
||||
if (!profilePath) return 'skipped';
|
||||
|
||||
const isWindows = platform() === 'win32';
|
||||
const exportLine = isWindows
|
||||
? `\n# Mosaic\n$env:Path = "${binDir};$env:Path"\n`
|
||||
: `\n# Mosaic\nexport PATH="${binDir}:$PATH"\n`;
|
||||
const block = managedBlockFor(binDir, isWindows);
|
||||
|
||||
// Check if already in profile
|
||||
let content = '';
|
||||
if (existsSync(profilePath)) {
|
||||
const content = readFileSync(profilePath, 'utf-8');
|
||||
if (content.includes(binDir)) {
|
||||
return 'already';
|
||||
}
|
||||
content = readFileSync(profilePath, 'utf-8');
|
||||
}
|
||||
|
||||
// Migration (#1327 S4): legacy unmarked blocks collapse into the managed
|
||||
// block, and an existing managed block is rewritten in place rather than
|
||||
// appended beside itself (S1/S3).
|
||||
const base = stripLegacyPathBlocks(withoutManagedBlock(content), isWindows);
|
||||
const trimmed = base.replace(/\n+$/, '');
|
||||
const next = trimmed.length === 0 ? block : `${trimmed}\n${block}`;
|
||||
|
||||
if (next === content) {
|
||||
return 'already';
|
||||
}
|
||||
|
||||
try {
|
||||
appendFileSync(profilePath, exportLine, 'utf-8');
|
||||
writeFileSync(profilePath, next, 'utf-8');
|
||||
return 'added';
|
||||
} catch {
|
||||
return 'skipped';
|
||||
@@ -286,7 +342,7 @@ export async function finalizeStage(
|
||||
}
|
||||
|
||||
// 7. PATH setup
|
||||
const pathAction = setupPath(state.mosaicHome, p);
|
||||
const pathAction = setupPath(state.mosaicHome, DEFAULT_MOSAIC_HOME);
|
||||
|
||||
let summaryShown = false;
|
||||
const showSummary = () => {
|
||||
|
||||
Reference in New Issue
Block a user