Compare commits

..
Author SHA1 Message Date
code-infra-01 284b2e3c23 fix(#1332): verdict fixture 2 - herestring, not printf-pipe into grep -qF
ci/woodpecker/pr/ci Pipeline failed
pipefail-early-exit.test.mjs (static scan) flagged the fixture-2 check as a
pipe into an early-exiting consumer; the sibling at fixture 1 already uses
the herestring form. Red reproduced locally (one scan entry, exactly the
flagged line), green after matching the sibling; verdict 4/4 and glyph 6/6
re-run green.
2026-08-20 12:06:56 -05:00
code-infra-01 69efad2f9a fix(#1262): adoption follow-ups - enumeration exclusion, SEND-honoring verdict suite, retire rc=2-is-normal guide text
ci/woodpecker/pr/ci Pipeline failed
- F5 (rev-code-02 blocker): sign test-send-message-glyph-agnostic.sh into
  test-enumeration-exclusions.txt beside its tmux siblings; the CI image
  ships no tmux, so the suite stays manually run (#1017 burndown).
- Adoption finding: the verdict suite hard-coded SEND to its sibling and
  ignored the SEND env var, so a red-first run against the shipping blob
  silently measured the patched copy instead (measured: shipping run
  printed PASS=4; with SEND honored it is PASS=3 FAIL=1, fixture 2 red,
  matching the recorded review numbers). SEND is now honored with the
  sibling as default, same contract as the glyph suite.
- F6/D19: framework FLEET-COMMS.md claimed 'rc=2 is the normal result
  when the target is an idle pi seat'. Post-fix rc=0 is normal for idle
  and busy pi seats; rc=2 on a healthy seat is a real report. Never-retry
  advice kept, softened to 'may be in the pane' for the unconfirmed arm.

Live verification on sb-it-1-dt (tmux 3.7b, pi glm-5.3 low, scratch
session): idle pi - shipping rc=2 'may be UNDELIVERED' while the seat
consumed the message and answered; patched rc=0 delivered, answered.
Busy pi mid-turn - shipping rc=2 while the pane accepted both messages
as steering input ('Steering: ...', 'Alt+Up to edit all queued
messages'); patched rc=0 delivered, both consumed and acted on after the
turn. Both D11 signatures: verdict now matches reality.
2026-08-20 11:31:44 -05:00
587cb19641 fix(tmux): confirm delivery by draft transition, not by prompt glyph (#1257)
send-message.sh located the REPL input box with grep -E '❯|^>|│ >'. That set is
Claude Code's box. A pi seat renders a bare U+2500 rule with no glyph, so on every
idle pi seat the capture succeeded, the grep matched nothing, status stayed
"unconfirmed", and the tool exited 2 "may be UNDELIVERED" with the paste and the
Enter both landed. The stderr tells the operator to retry, and that retry is the
duplicate delivery reported against the same tool.

Confirmation is now runtime-agnostic: our message tail sits on the input line
(located by cursor row, no glyph) before Enter and has left it after. That
transition is positive proof of submission.

Absence still proves nothing, which is the guard the 2026-08 fix was reaching for
and got backwards. Two positive checks keep it:

  - a prompt box that IS locatable and still carries our tail => draft, exit 2.
    This covers the cursor-row blind spot: a cooked pane whose foreground process
    never reads stdin echoes the paste through the kernel line discipline and
    moves the cursor off it on Enter, which by cursor row alone is indistinguishable
    from a real submit.
  - no draft ever observed on the input line => unconfirmed, non-zero.

Tests, both red-first against the shipping blob d397907:

  test-send-message-glyph-agnostic.sh (new, 6 fixtures)  4/6 -> 6/6
  test-send-message-verdict.sh (fixture 2 reshaped, 2b added)  3/4 -> 4/4

Fixture 2 of the verdict suite asserted exit 2 for a glyphless pane that submits
and was labelled "false-positive FIXED". A pi seat is that fixture, so the suite
was locking the bug in. It is reshaped deliberately, and the guard it was credited
with moves to new fixture 2b (glyphless AND non-submitting, raw/no-echo) so the
"never infer delivered from absence" property is tested positively rather than as
a side effect.

Measured on tmux 3.7b (sb-it-1-dt), 3.5a (fomo-lin), and dragon-lin.

Co-authored-by: scooby <[email protected]>
2026-08-20 11:24:18 -05:00
Ghostgate-merge-01Ghost <>
9b6869fab7 fix(#1179): require security authority wiring (#1189)
ci/woodpecker/push/publish Pipeline was canceled
Co-authored-by: Ghost <>
2026-08-20 16:19:45 +00:00
15 changed files with 646 additions and 282 deletions
@@ -0,0 +1,332 @@
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,
null,
{ get: vi.fn().mockResolvedValue(null), renew: vi.fn().mockResolvedValue(undefined) } as never,
null,
{ collect: vi.fn().mockResolvedValue(undefined) } as never,
operatorMemory as never,
+9 -11
View File
@@ -132,9 +132,8 @@ 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 | null,
private readonly systemOverride: SystemOverrideService,
@Optional()
@Inject(PreferencesService)
private readonly preferencesService: PreferencesService | null,
@@ -709,23 +708,22 @@ 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)
// Prepend session-scoped system override if present (renew TTL on each turn).
// Required instruction-authority wiring is consulted before session/provider effects.
let effectiveMessage = `${message}${attachmentContext}`;
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}`);
}
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,6 +80,10 @@ const mockMcpClient = {
getToolDefinitions: vi.fn(() => []),
};
const allowAuthorization = {
authorize: vi.fn().mockResolvedValue({ allowed: true }),
};
function buildService(
redis: typeof mockRedis | null = mockRedis,
mcpClient: {
@@ -98,6 +102,7 @@ function buildService(
null,
mockChatGateway as never,
mcpClient as never,
allowAuthorization as never,
);
}
@@ -35,9 +35,8 @@ export class CommandExecutorService {
@Inject(forwardRef(() => ChatGateway))
private readonly chatGateway: ChatGateway | null,
@Inject(McpClientService) private readonly mcpClient: McpClientService,
@Optional()
@Inject(CommandAuthorizationService)
private readonly authorization: CommandAuthorizationService | null = null,
private readonly authorization: CommandAuthorizationService,
) {}
async execute(
@@ -57,13 +56,13 @@ export class CommandExecutorService {
};
}
const authorization = await this.authorization?.authorize(
const authorization = await this.authorization.authorize(
def,
payload,
userId,
payload.approvalId,
);
if (authorization && !authorization.allowed) {
if (!authorization.allowed) {
return { command, conversationId, success: false, message: authorization.reason };
}
@@ -171,7 +170,7 @@ export class CommandExecutorService {
const def = this.registry
.getManifest()
.commands.find((command) => command.name === payload.command);
if (!def || !this.authorization) return null;
if (!def) return null;
return this.authorization.createApproval(def, payload, scope.userId);
}
@@ -55,6 +55,10 @@ const mockMcpClient = {
reconnectServer: vi.fn().mockResolvedValue(undefined),
};
const allowAuthorization = {
authorize: vi.fn().mockResolvedValue({ allowed: true }),
};
// ─── Helpers ─────────────────────────────────────────────────────────────────
function buildRegistry(): CommandRegistryService {
@@ -74,6 +78,7 @@ function buildExecutor(registry: CommandRegistryService): CommandExecutorService
null, // reloadService (optional)
null, // chatGateway (optional)
mockMcpClient as never,
allowAuthorization as never,
);
}
@@ -159,6 +159,7 @@ 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' };
@@ -0,0 +1,77 @@
# #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.
@@ -53,21 +53,23 @@ sends, it does not auto-reply.
### Exit codes
| rc | Meaning |
| --- | ---------------------------------------------- |
| 0 | delivered or queued |
| 1 | target session not found |
| 2 | text reached the pane but is **still a draft** |
| 3 | usage error (bad class, missing `-s`) |
| rc | Meaning |
| --- | -------------------------------------------------------------------------------------------- |
| 0 | delivered or queued |
| 1 | target session not found |
| 2 | submission unconfirmed: draft still on the input line, or no positive evidence of submission |
| 3 | usage error (bad class, missing `-s`) |
**Never retry on rc=2.** The message is in the target pane; retrying double-sends it. Confirm
instead:
**Never retry on rc=2.** The message may be in the target pane, and a retry can double-send it.
Confirm instead:
```bash
tmux capture-pane -p -t <session>:0.0 | tail -20
```
rc=2 is the normal result when the target is an idle pi seat.
rc=0 is the normal result for both idle and busy pi seats (submission confirmed by draft
transition, not by prompt glyph). rc=2 on a healthy seat is exceptional — treat it as a real
report and investigate the pane.
## Durable comms
@@ -30,6 +30,7 @@ packages/mosaic/framework/tools/git/test-lane-brief-pr-linkage.sh | unmeasured i
# --- tools/tmux: require a live tmux server ---
packages/mosaic/framework/tools/tmux/test-send-message-socket.sh | requires a real tmux server on a throwaway socket; CI image ships no tmux; #1017 burndown (needs tmux in image or a signed permanent exclusion)
packages/mosaic/framework/tools/tmux/test-send-message-verdict.sh | requires real tmux-pane fixtures on a throwaway socket; CI image ships no tmux; #1017 burndown (same condition as its sibling)
packages/mosaic/framework/tools/tmux/test-send-message-glyph-agnostic.sh | requires real tmux-pane fixtures on a throwaway socket; CI image ships no tmux; #1017 burndown (same condition as its siblings) — signed at adoption of #1262 (rev-code-02 F5), red-first verified on sb-it-1-dt
# --- single-suite directories: unmeasured in CI ---
@@ -97,13 +97,34 @@ printf '%s' "$MSG" | "${tmux_cmd[@]}" load-buffer -b "$BUF" -
# would otherwise accumulate forever.
sleep 0.5
# 2) Submit, then POSITIVELY confirm submission; flush with another Enter if it is
# still a draft. Success requires positive evidence — the queued banner, OR the
# REPL input box located AND clear of our message tail. The historical bug was
# treating ABSENCE of a draft as delivery: if the prompt glyph was never matched
# (wrong pane / prompt-glyph drift), an unsubmitted message read as "delivered"
# and worker->lead relays stalled silently. We now default to UNCONFIRMED and only
# upgrade to delivered on positive evidence; anything we cannot confirm fails loud.
# 2) Submit, then POSITIVELY confirm submission by DRAFT TRANSITION, not by prompt
# glyph. The historical bug was treating ABSENCE of a draft as delivery; the
# 2026-08 fix over-corrected to glyph inference (grep '|^>|│ >'), which locates
# only Claude Code's box and false-NEGATIVES every glyphless REPL (pi renders a
# U+2500 rule, no glyph) — a delivered message reported "UNDELIVERED", driving a
# retry that duplicates it. Runtime-agnostic evidence: our message tail sits on
# the INPUT line (located by the cursor row, not a glyph) BEFORE Enter, and has
# LEFT it AFTER — that transition is positive proof of submission and needs no
# glyph. Absence alone still never means delivered: if we never saw our draft on
# the input line we stay UNCONFIRMED (wrong/dead pane), and a draft that never
# leaves the input line stays a DRAFT (exit 2), preserving both historical guards.
_cursor_line() { # echo the pane's current input (cursor) line, glyph-free
local cy line
cy=$("${tmux_cmd[@]}" display-message -p -t "$EFFECTIVE_TARGET" -F '#{cursor_y}' 2>/dev/null) || return 1
[ -n "$cy" ] || return 1
"${tmux_cmd[@]}" capture-pane -t "$EFFECTIVE_TARGET" -p 2>/dev/null | sed -n "$((cy + 1))p"
}
_draft_on_input() { # true iff our message tail is sitting on the input line now
[ -n "$snippet" ] || return 1
grep -qF "$snippet" <<<"$(_cursor_line)"
}
# Baseline: after the paste, our draft must be on the input line. This is positive
# proof we are on the right pane and the paste landed — the anchor the transition
# check measures against.
saw_draft=0
_draft_on_input && saw_draft=1
status="unconfirmed"
for attempt in $(seq 1 $((RETRIES + 1))); do
"${tmux_cmd[@]}" send-keys -t "$EFFECTIVE_TARGET" Enter
@@ -113,20 +134,26 @@ for attempt in $(seq 1 $((RETRIES + 1))); do
if grep -qF "$QUEUED_RE" <<<"$pane"; then
status="queued"; break
fi
# Locate the REPL input box (prompt glyph). If we cannot see it, we have NO
# evidence of submission state — stay UNCONFIRMED and retry; never infer delivery.
# POSITIVE draft evidence from a located prompt box, when one exists. This is the
# cursor-row check's blind spot: a pane in COOKED mode (a plain shell whose
# foreground process never reads stdin) echoes our paste via the kernel line
# discipline and moves the cursor off it on Enter, which is indistinguishable from
# a real submit by cursor row alone. If a prompt box IS locatable and still carries
# our tail, that is affirmative proof the message was not consumed. Absence of a
# glyph is still never used for anything — that inference is the original E7 bug.
promptline=$(printf '%s' "$pane" | grep -E '|^>|│ >' | tail -1)
if [ -z "$promptline" ]; then
status="unconfirmed"; continue
fi
# Input box located AND still carrying our tail => unsubmitted draft. Flush + retry.
# (Submitted messages scroll up into history; a draft stays on the line.)
if [ -n "$snippet" ] && grep -qF "$snippet" <<<"$promptline"; then
if [ -n "$promptline" ] && [ -n "$snippet" ] && grep -qF "$snippet" <<<"$promptline"; then
status="draft"; continue
fi
# Input box located AND clear of our tail => positively submitted. This is the
# only path to success besides the queued banner.
status="delivered"; break
if [ "$saw_draft" = 1 ]; then
if _draft_on_input; then
status="draft"; continue # still on the input line => not submitted; flush + retry
fi
status="delivered"; break # left the input line => positively submitted
fi
# No confirmed baseline yet: try to (re)acquire it; never infer delivery from absence.
if _draft_on_input; then saw_draft=1; status="draft"; continue; fi
status="unconfirmed"; continue
done
[ "$VERBOSE" = 1 ] && { echo "--- pane tail ($TARGET) ---"; printf '%s\n' "$pane" | tail -4; echo "---"; }
@@ -0,0 +1,97 @@
#!/usr/bin/env bash
# Red-first regression test for E7 (#1017 task 2): the confirm-check must bind
# "delivered" to WHETHER THE MESSAGE WAS SUBMITTED, not to which runtime's prompt
# glyph is present. A pi seat renders a U+2500 rule input box with no /^>/│ >
# glyph; send-message.sh:118 locates the box only by glyph, so a genuinely
# delivered message on a glyphless REPL falsely reports exit 2 "may be UNDELIVERED",
# and the operator's rc=2-driven retry duplicates it.
#
# Parameterized on $SEND: RED against the shipping blob (B and D fail), GREEN
# against a candidate patch. No pi; no fake HOME; hermetic throwaway socket.
#
# Submission counting is EXACT and terminal-echo-independent: the fixture message
# is `echo <tok> >>SINK`; each real submission appends one line. wc -l SINK ==
# number of times the REPL actually executed the send. This does not depend on how
# many times the marker string is painted on screen.
set -u
SEND="${SEND:?set SEND=/path/to/send-message.sh}"
SOCKET="glyphagnostic-$$"
TMP="$(mktemp -d)"
tmux() { command tmux -L "$SOCKET" "$@"; }
cleanup() { command tmux -L "$SOCKET" kill-server 2>/dev/null; rm -rf "$TMP"; }
trap cleanup EXIT
pass=0; fail=0
ok() { printf 'ok %s\n' "$1"; pass=$((pass+1)); }
no() { printf 'FAIL %s -- %s\n' "$1" "$2"; fail=$((fail+1)); }
mk() { tmux new-session -d -s "$1" -x 120 -y 40 -c "$TMP" "PS1='$2' exec bash --noprofile --norc -i"; sleep 0.5; }
subs() { [ -f "$1" ] && wc -l <"$1" | tr -d ' ' || echo 0; } # exact submission count
echo "SEND=$SEND tmux $(command tmux -V | awk '{print $2}')"
# --- A (control): glyph box () that submits => exit 0, exactly one submission.
mk ctl ' '
SINK="$TMP/sink.ctl"
out=$("$SEND" -L "$SOCKET" -t ctl -m "echo x >>'$SINK'" 2>"$TMP/e.ctl"); rc=$?; sleep 0.4
if [ "$rc" = 0 ] && [ "$(subs "$SINK")" = 1 ]; then
ok "control: -box submits => exit 0, exactly one submission"
else no "control: -box submits => exit 0, one submission" "rc=$rc subs=$(subs "$SINK") err=[$(cat "$TMP/e.ctl")]"; fi
# --- B (THE false-rc regression): glyphless U+2500 box that SUBMITS. Message lands
# (subs==1) yet shipping reports exit 2. Must be exit 0.
mk sub $'──────── \n'
SINK="$TMP/sink.sub"
out=$("$SEND" -L "$SOCKET" -t sub -m "echo x >>'$SINK'" 2>"$TMP/e.sub"); rc=$?; sleep 0.4
if [ "$rc" = 0 ] && [ "$(subs "$SINK")" = 1 ]; then
ok "glyphless: U+2500 box that submits => exit 0 (delivered, not 'UNDELIVERED')"
else no "glyphless: U+2500 box that submits => exit 0" \
"rc=$rc subs=$(subs "$SINK")(delivered=$([ "$(subs "$SINK")" -ge 1 ] && echo yes||echo no)) err=[$(cat "$TMP/e.sub")]"; fi
# --- D (duplicate arm): operator follows the rc=2 stderr and retries once. On the
# glyphless box, shipping => two submissions (the reported duplicate). The
# property: one logical send => exactly one submission. Same fix closes it.
mk dup $'──────── \n'
SINK="$TMP/sink.dup"
tries=0
for attempt in 1 2; do
tries=$((tries+1))
out=$("$SEND" -L "$SOCKET" -t dup -m "echo x >>'$SINK'" 2>/dev/null); rc=$?
sleep 0.4
[ "$rc" = 0 ] && break # operator stops retrying only when told delivered
done
if [ "$(subs "$SINK")" = 1 ]; then
ok "duplicate: one logical send (rc-driven retry) => exactly one submission (tries=$tries)"
else no "duplicate: one logical send => exactly one submission" "submissions=$(subs "$SINK") tries=$tries"; fi
# --- E (faithful hung managed TUI, NOT a cooked shell): raw/no-echo, paints nothing.
# A cooked `sleep infinity` echoes the paste via the kernel line discipline and
# false-passes a cursor-row fix that is correct on real seats (measured). So: raw.
mk_rawstuck() { tmux new-session -d -s "$1" -x 120 -y 40 -c "$TMP" \
"bash --noprofile --norc -c 'stty -echo -icanon min 1 time 0 2>/dev/null; exec sleep infinity'"; sleep 0.5; }
mk_rawstuck estuck
SINK="$TMP/sink.estuck"
out=$("$SEND" -L "$SOCKET" -t estuck -r 1 -m "this stuck draft was never submitted" 2>/dev/null); rc=$?
sleep 0.3
if [ "$rc" != 0 ] && [ "$(subs "$SINK")" = 0 ]; then
ok "raw/no-echo stuck TUI (not submitted) => non-zero (no false delivered)"
else no "raw stuck TUI must NOT report delivered" "rc=$rc subs=$(subs "$SINK")"; fi
# --- F (busy/queued branch, your BUSY-not-runtime finding): glyphless pane rendering the
# queued banner, never consuming. QUEUED_RE :113 fires before the glyph grep => rc=0.
mk_busy() { tmux new-session -d -s "$1" -x 120 -y 40 -c "$TMP" \
"bash --noprofile --norc -c 'printf \"Press up to edit queued messages\n\"; exec sleep infinity'"; sleep 0.5; }
mk_busy ebusy
SINK="$TMP/sink.ebusy"
out=$("$SEND" -L "$SOCKET" -t ebusy -m "echo x >>'$SINK'" 2>/dev/null); rc=$?; sleep 0.3
if [ "$rc" = 0 ]; then
ok "busy/queued-banner glyphless => exit 0 (queued is delivery; runtime owns custody)"
else no "busy/queued-banner must report delivered" "rc=$rc"; fi
# --- C (historical-bug guard): unresolvable target. No pane ever carried our draft
# => must fail, never infer delivered from absence of a glyph/snippet.
if out=$("$SEND" -L "$SOCKET" -t "nonexistent-$$" -m "echo x >>'$TMP/sink.wrong'" 2>/dev/null); then
no "wrong-pane: unresolvable target must NOT report success" "expected non-zero, got 0"
else ok "wrong-pane: unresolvable target => non-zero (no false delivered)"; fi
echo "---"; echo "pass=$pass fail=$fail"
[ "$fail" = 0 ]
@@ -4,16 +4,19 @@
#
# 1. DELIVERED — a REPL that renders a ` ` input box and submits on Enter
# (text scrolls to history, box clears) => exit 0 "✓ delivered".
# 2. UNCONFIRMED — a pane with NO locatable prompt glyph. This is the exact
# historical FALSE POSITIVE: pre-patch it printed "✓ delivered"
# exit 0; post-patch it MUST fail loud (exit 2, stderr
# "could not confirm submission").
# 2. DELIVERED — a pane with NO prompt glyph that DOES submit => exit 0. A pi
# seat is this fixture (U+2500 rule, no glyph). Reshaped for
# #1257; see the note at the fixture for why the old exit-2
# assertion was wrong.
# 2b. UNCONFIRMED— a glyphless pane that never submits (raw/no-echo hung TUI)
# => must fail loud. This carries the historical
# false-positive guard that fixture 2 used to be credited with.
# 3. DRAFT — a ` `-prompt pane that never submits (message stays on the
# input line) => exit 2, stderr "unsubmitted draft".
set -uo pipefail
HERE=$(cd -- "$(dirname -- "$0")" && pwd)
SEND="$HERE/send-message.sh"
SEND="${SEND:-$HERE/send-message.sh}"
SOCKET="verdict-test-$RANDOM-$$"
TMP=$(mktemp -d)
trap 'tmux -L "$SOCKET" kill-server >/dev/null 2>&1 || true; rm -rf "$TMP"' EXIT
@@ -37,19 +40,44 @@ else
no "delivered: -prompt REPL that submits => exit 0 ✓ delivered" "rc=$rc out=[$out] err=[$(cat "$TMP/e1")]"
fi
# --- Fixture 2: NO prompt glyph (default bash PS1). THE regression: pre-patch this
# was a silent false-positive "delivered"; post-patch it must be unconfirmed→exit 2.
# --- Fixture 2: NO prompt glyph, and the pane DOES submit (interactive bash).
# RESHAPED 2026-08-16 (#1257), deliberately. This fixture previously asserted
# exit 2 here and was labelled "false-positive FIXED". That assertion was wrong,
# and locking it in is what kept E7 alive: the pane submits, so "delivered" is
# the truth, and a pi seat — whose input box is a bare U+2500 rule with no glyph
# — IS this fixture. Reporting exit 2 for it told operators a delivered message
# may be undelivered, and the retry that advice invites is the duplicate.
#
# The guard this fixture was reaching for is real and is NOT dropped: "never
# infer delivered from absence" is now enforced positively by fixture 2b below
# (glyphless AND not submitting => must fail) and by fixture 3 (locatable box
# still carrying our tail => draft). Absence alone decides nothing either way.
tmux -L "$SOCKET" new-session -d -s noglyph -c "$TMP" \
'PS1="sh-noglyph$ " exec bash --noprofile --norc -i'
sleep 0.3
if out=$("$SEND" -L "$SOCKET" -t "=noglyph" -m "verdict fixture two must fail loud" 2>"$TMP/e2"); then
no "unconfirmed: glyphless pane must NOT report success" "expected exit 2, got 0 (out=[$out])"
out=$("$SEND" -L "$SOCKET" -t "=noglyph" -m "verdict fixture two must fail loud" 2>"$TMP/e2"); rc=$?
if [ "$rc" -eq 0 ] && grep -qF "✓ delivered" <<<"$out"; then
ok "delivered: glyphless pane that submits => exit 0 (runtime-agnostic, E7 FIXED)"
else
no "delivered: glyphless pane that submits => exit 0" "rc=$rc out=[$out] err=[$(cat "$TMP/e2")]"
fi
# --- Fixture 2b: NO prompt glyph AND never submits — a hung managed TUI holding the
# terminal in raw/no-echo, which is what a stuck agent seat actually is (measured
# on live pi: stty -echo -icanon). Nothing is echoed, nothing is consumed, so
# there is no positive evidence of submission and the tool MUST fail loud. This
# is the historical false-positive guard, kept as a positive test.
tmux -L "$SOCKET" new-session -d -s rawstuck -c "$TMP" \
'bash --noprofile --norc -c "stty -echo -icanon min 1 time 0 2>/dev/null; exec sleep infinity"'
sleep 0.3
if out=$("$SEND" -L "$SOCKET" -t "=rawstuck" -r 1 -m "verdict fixture two-b never submitted" 2>"$TMP/e2b"); then
no "unconfirmed: glyphless hung TUI must NOT report success" "expected non-zero, got 0 (out=[$out])"
else
rc=$?
if [ "$rc" -eq 2 ] && grep -qF "could not confirm submission" "$TMP/e2"; then
ok "unconfirmed: glyphless pane => exit 2 + 'could not confirm submission' (false-positive FIXED)"
if [ "$rc" -ne 0 ] && grep -qF "could not confirm submission" "$TMP/e2b"; then
ok "unconfirmed: glyphless hung TUI (raw/no-echo) => non-zero + 'could not confirm submission'"
else
no "unconfirmed: glyphless pane => exit 2 + stderr" "rc=$rc err=[$(cat "$TMP/e2")]"
no "unconfirmed: glyphless hung TUI => non-zero + stderr" "rc=$rc err=[$(cat "$TMP/e2b")]"
fi
fi
@@ -1,152 +0,0 @@
/**
* 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');
});
});
+18 -74
View File
@@ -1,12 +1,11 @@
import { spawnSync } from 'node:child_process';
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { existsSync, readFileSync, appendFileSync } 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,
@@ -145,87 +144,32 @@ function runDoctor(mosaicHome: string): DoctorResult {
type PathAction = 'already' | 'added' | 'skipped';
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.';
/**
* 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';
}
function setupPath(mosaicHome: string, _p: WizardPrompter): PathAction {
const binDir = join(mosaicHome, 'bin');
const currentPath = process.env['PATH'] ?? '';
if (currentPath.includes(binDir)) {
return 'already';
}
const profilePath = getShellProfilePath();
if (!profilePath) return 'skipped';
const isWindows = platform() === 'win32';
const block = managedBlockFor(binDir, isWindows);
const exportLine = isWindows
? `\n# Mosaic\n$env:Path = "${binDir};$env:Path"\n`
: `\n# Mosaic\nexport PATH="${binDir}:$PATH"\n`;
let content = '';
// Check if already in profile
if (existsSync(profilePath)) {
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';
const content = readFileSync(profilePath, 'utf-8');
if (content.includes(binDir)) {
return 'already';
}
}
try {
writeFileSync(profilePath, next, 'utf-8');
appendFileSync(profilePath, exportLine, 'utf-8');
return 'added';
} catch {
return 'skipped';
@@ -342,7 +286,7 @@ export async function finalizeStage(
}
// 7. PATH setup
const pathAction = setupPath(state.mosaicHome, DEFAULT_MOSAIC_HOME);
const pathAction = setupPath(state.mosaicHome, p);
let summaryShown = false;
const showSummary = () => {