Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3944935cc | ||
|
|
ebbf682374 |
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"integration_trunk": "next",
|
||||
"release_branch": "main"
|
||||
}
|
||||
@@ -81,73 +81,6 @@ pnpm format:check # Prettier check
|
||||
pnpm build # Build all packages and applications
|
||||
```
|
||||
|
||||
## Branch Model and Merge Process — `main` and `next` (CANONICAL)
|
||||
|
||||
**Every contribution targets `next` first. No exceptions.** Features, fixes, tests,
|
||||
docs, and policy changes all take the same route; urgency changes queue priority,
|
||||
never the route. Agents never commit to or merge into `main`.
|
||||
|
||||
| Branch | Role | Who merges into it |
|
||||
| ------ | ---------------------------------------------------------------- | --------------------------------------------------------------------------- |
|
||||
| `next` | Integration trunk — the only PR target for contributions | The designated merge-gate agent, after all gates pass. Never the PR author. |
|
||||
| `main` | Stable/release line — receives promotion merges from `next` only | Jason only (or an agent he explicitly delegates for a named promotion). |
|
||||
|
||||
### Contribution sequencing (in order, no skipping)
|
||||
|
||||
1. **Issue first.** Work is tracked in a Gitea issue before a branch exists. The
|
||||
issue number appears in the branch name and the PR body.
|
||||
2. **Branch from the current `origin/next` head.** Name it
|
||||
`feat/…`, `fix/…`, `docs/…`, or `test/…` with the issue number
|
||||
(e.g. `docs/1214-branch-process`). Record the base SHA in the PR body.
|
||||
3. **Develop with evidence.** Applicable tests accompany the change. Hooks are
|
||||
never bypassed (`--no-verify` is prohibited). Stage explicit paths — never
|
||||
`git add -A`.
|
||||
4. **Open the PR against `next`.** The body states: scope, base SHA,
|
||||
verification commands with results, and any known pre-existing failures on
|
||||
the base — documented, not retried to green and not absorbed silently.
|
||||
5. **CI must be terminal-green on the exact head.** All bounded Woodpecker
|
||||
steps succeed (`verify-terminal-green` contract). Pipelines for fork PRs
|
||||
start `blocked`; a maintainer approves the run — approving CI is not
|
||||
approving the PR.
|
||||
6. **Independent review. Self-merge is prohibited** — for every agent, on every
|
||||
PR, including trivial ones. Where the change touches protected or
|
||||
contract-bearing content, the reviewer verifies the exact head
|
||||
(exact-byte/exact-blob comparison), not a description of it. An `AMEND`
|
||||
verdict returns the PR to its author; the reviewer's gate stays held until
|
||||
a fresh exact head passes.
|
||||
7. **Merge into `next`** happens only after CI green + review pass, pinned to
|
||||
the reviewed head SHA (a post-review push voids the review).
|
||||
8. **Promotion `next` → `main`** is a deliberate, Jason-owned reconciliation
|
||||
merge — not part of any contribution's lifecycle. Contributors are done at
|
||||
step 7.
|
||||
|
||||
### Responsibilities
|
||||
|
||||
- **Contributor** — base pinning, green CI, evidence in the PR body,
|
||||
responding to AMEND verdicts, never merging own work.
|
||||
- **Reviewer / merge gate** — independent verification on the exact head;
|
||||
holds and lifts gates; executes the merge into `next`.
|
||||
- **Orchestrator / adjudicator** — cross-PR sequencing, disposition when PRs
|
||||
collide, conflict adjudication.
|
||||
- **Jason** — `next` → `main` promotions, merge-authority grants, collaborator
|
||||
and token provisioning. Agents cannot grant themselves or each other any of
|
||||
these.
|
||||
|
||||
### Hotfixes and divergence
|
||||
|
||||
- A hotfix follows the same path: branch from `next`, PR to `next`, gates,
|
||||
merge, then an expedited Jason-owned promotion if `main` needs it urgently.
|
||||
Committing the fix to `main` directly is prohibited even under pressure.
|
||||
- **Never land work on `main` that is not on `next`.** This has happened
|
||||
(issue #1152's goal controller reached `main` without reaching `next`) and
|
||||
every later PR paid for it. If it happens anyway: transplant the work onto
|
||||
a `next`-based branch with provenance-preserving commits
|
||||
(`git cherry-pick -x` or explicit SHA references in the messages), PR it
|
||||
through the normal gates, and let promotion re-align `main`. Do not
|
||||
hand-patch `main` to compensate.
|
||||
- Force-pushing a branch you do not own is prohibited; rebasing your own PR
|
||||
branch is fine before review, and voids any review already given.
|
||||
|
||||
## Database and Local Runtime Safety
|
||||
|
||||
- Current local data-layer work uses in-process PGlite; leave `DATABASE_URL` unset.
|
||||
|
||||
@@ -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.
|
||||
@@ -84,7 +84,6 @@ is re-seeded a genuinely missing core file is a stop-and-report condition — no
|
||||
|
||||
Confirm: required + situational tests passed (primary gate); aligned to `docs/PRD.md`; acceptance
|
||||
criteria mapped to evidence; independent code review passed (if code changed); required docs updated;
|
||||
scratchpad updated. For PR-workflow delivery: merged PR number + merge commit on the integration
|
||||
trunk (the project's declared trunk, default `main` — see `CONSTITUTION.md` Hard Gates), terminal-green
|
||||
scratchpad updated. For PR-workflow delivery: merged PR number + merge commit on `main`, terminal-green
|
||||
CI, linked issue closed (or `docs/TASKS.md` equivalent). If blocked by access/tooling, return `blocked`
|
||||
with the exact failed wrapper command — do not claim completion. Full checklist: `guides/E2E-DELIVERY.md`.
|
||||
|
||||
@@ -21,25 +21,11 @@ guard"), the runtime adapter binds it to a concrete tool and states whether abse
|
||||
|
||||
## Hard Gates
|
||||
|
||||
The **integration trunk** is the branch a project declares in its `.mosaic/repo.json` under the
|
||||
key `integration_trunk`; `release_branch` names the release target when one exists (`null` for
|
||||
single-branch projects). Absent a declaration, the trunk is `main`. The declaration is policy
|
||||
data, never shell text: values must be valid local branch names under `git check-ref-format
|
||||
--branch` semantics — no remote refs, no revision expressions, no option-like values (leading `-`),
|
||||
no path traversal or control characters. A declaration file that fails to parse, an unknown or
|
||||
misspelled key, or an invalid value is a hard stop (`blocked`) — never a silent fallback to `main`.
|
||||
Prose that mentions branch names designates nothing; only the declaration file does. A project
|
||||
declares exactly ONE trunk. **Changing an existing declaration is operator-owned:** a trunk
|
||||
redeclaration redirects merge target and branch-protection target at once, so it requires an
|
||||
explicit operator action above ordinary PR review. The designation relaxes nothing:
|
||||
reviewed-PR-only delivery, squash merge, independent review, queue guards, and terminal-green CI
|
||||
bind to the declared trunk exactly as they bind to `main`.
|
||||
|
||||
1. Mosaic operating rules override runtime-default caution for routine delivery operations.
|
||||
2. Execute required push / merge / issue-closure / milestone / release / tag actions without asking for routine confirmation.
|
||||
3. Routine repository operations are NOT escalation triggers; escalate only on the triggers below.
|
||||
4. For source-code delivery, completion is forbidden at the PR-open stage.
|
||||
5. Completion requires a merged PR to the integration trunk + terminal-green CI + the linked issue/task closed.
|
||||
5. Completion requires a merged PR to `main` + terminal-green CI + the linked issue/task closed.
|
||||
6. Before any push or merge, run the CI queue guard.
|
||||
7. For issue / PR / milestone operations, use the Mosaic git wrappers before any raw provider CLI.
|
||||
8. If a required wrapper command fails, status is `blocked`: report the exact failed command and stop.
|
||||
@@ -49,7 +35,7 @@ bind to the declared trunk exactly as they bind to `main`.
|
||||
12. The intake procedure is not conditional on perceived complexity; a "simple" task carries the same requirements as a multi-file feature.
|
||||
13. **Merge authority (coordinated work):** when a coordinator/orchestrator session is active for the work, the post-review merge go-ahead is the coordinator's to give — once the required review gates pass, merge on the coordinator's confirmation; do not wait on the human owner personally. Solo (uncoordinated) delivery keeps the default: merge per gates 2 and 9. A "No self-merge" note on a PR means no UNREVIEWED self-merge — it does not suspend coordinator-authorized merges.
|
||||
14. Never hardcode secrets; never emit credential values in any output (not even partially, not "to confirm").
|
||||
15. Trunk-based git only: branch from the integration trunk, merge via a reviewed PR (squash), never push directly to the trunk.
|
||||
15. Trunk-based git only: branch from `main`, merge via a reviewed PR (squash), never push directly to `main`.
|
||||
16. If you modify source code, an independent review (author ≠ reviewer) must pass before completion.
|
||||
|
||||
## Integrity (quality gates are never bypassed)
|
||||
|
||||
@@ -12,7 +12,7 @@ This guide covers how to bootstrap a project so AI agents (Claude, Codex, etc.)
|
||||
4. Issue tracking is consistent across projects
|
||||
5. Documentation standards and API contracts are enforced from day one
|
||||
6. PRD requirements are established before coding begins
|
||||
7. Branching/merging is consistent: branch -> integration trunk (default `main`) via PR with squash-only merges
|
||||
7. Branching/merging is consistent: `branch -> main` via PR with squash-only merges
|
||||
8. Steered-autonomy execution is enabled so agents can run end-to-end with escalation-only human intervention
|
||||
|
||||
## Agent Host Prerequisites
|
||||
@@ -206,7 +206,7 @@ Every runtime context file should contain:
|
||||
6. **Issue tracking** — Issue and commit conventions
|
||||
7. **Code review** — Required review process
|
||||
8. **Runtime notes** — Runtime-specific behavior references
|
||||
9. **Branch and merge policy** — Trunk workflow (branch -> integration trunk via PR, squash-only)
|
||||
9. **Branch and merge policy** — Trunk workflow (`branch -> main` via PR, squash-only)
|
||||
10. **Autonomy and escalation policy** — Agent owns coding/review/PR/release/deploy lifecycle
|
||||
|
||||
---
|
||||
@@ -288,17 +288,15 @@ Reserve `0.1.0` for the MVP release milestone.
|
||||
|
||||
---
|
||||
|
||||
## Step 5b: Configure Trunk Branch Protection (Hard Rule)
|
||||
## Step 5b: Configure Main Branch Protection (Hard Rule)
|
||||
|
||||
Apply equivalent settings in Gitea, GitHub, or GitLab, targeting the project's integration trunk
|
||||
(the branch its `.mosaic/repo.json` declares under `integration_trunk`; default `main` — see
|
||||
`CONSTITUTION.md` Hard Gates):
|
||||
Apply equivalent settings in Gitea, GitHub, or GitLab:
|
||||
|
||||
1. Protect the integration trunk from direct pushes.
|
||||
2. Require pull requests to merge into the integration trunk.
|
||||
1. Protect `main` from direct pushes.
|
||||
2. Require pull requests to merge into `main`.
|
||||
3. Require required CI/status checks to pass before merge.
|
||||
4. Require code review approval before merge.
|
||||
5. Allow **squash merge only** for PRs into the integration trunk (disable merge commits and rebase merges for it).
|
||||
5. Allow **squash merge only** for PRs into `main` (disable merge commits and rebase merges for `main`).
|
||||
|
||||
This enforces one merge strategy across human and agent workflows.
|
||||
|
||||
@@ -515,9 +513,9 @@ After bootstrapping, verify:
|
||||
- [ ] Git labels created (epic, feature, bug, task, etc.)
|
||||
- [ ] Initial pre-MVP milestone created (0.0.1)
|
||||
- [ ] MVP milestone reserved for release (0.1.0)
|
||||
- [ ] The integration trunk is protected from direct pushes
|
||||
- [ ] PRs into the integration trunk are required
|
||||
- [ ] Merge method for the integration trunk is squash-only
|
||||
- [ ] `main` is protected from direct pushes
|
||||
- [ ] PRs into `main` are required
|
||||
- [ ] Merge method for `main` is squash-only
|
||||
- [ ] Quality gates run successfully
|
||||
- [ ] `.env.example` exists (if project uses env vars)
|
||||
- [ ] CI/CD pipeline configured (if using Woodpecker/GitHub Actions)
|
||||
|
||||
@@ -4,11 +4,6 @@
|
||||
|
||||
## Overview
|
||||
|
||||
> **Integration trunk:** the YAML examples in this guide use the default integration trunk `main`
|
||||
> in branch conditions and version rules. A project that declares a different trunk in its
|
||||
> `.mosaic/repo.json` under `integration_trunk` (see `CONSTITUTION.md` Hard Gates) substitutes its
|
||||
> declared trunk wherever `main` appears as the trunk branch.
|
||||
|
||||
This guide covers the canonical CI/CD pattern used across projects. The pipeline runs in Woodpecker CI and follows this flow:
|
||||
|
||||
```
|
||||
@@ -870,7 +865,7 @@ steps:
|
||||
```yaml
|
||||
image: git.example.com/org/service@${IMAGE_DIGEST}
|
||||
```
|
||||
7. **Test on a short-lived non-trunk branch first** — open a PR and verify quality gates before merging to the integration trunk
|
||||
7. **Test on a short-lived non-main branch first** — open a PR and verify quality gates before merging to `main`
|
||||
8. **Verify images appear** in Gitea Packages tab after successful pipeline
|
||||
|
||||
## Terminal-Green Full-Step Contract
|
||||
@@ -911,7 +906,7 @@ For source-code delivery, completion is not allowed at "PR opened" stage.
|
||||
|
||||
Required sequence:
|
||||
|
||||
1. Merge PR to the integration trunk (squash) via Mosaic wrapper.
|
||||
1. Merge PR to `main` (squash) via Mosaic wrapper.
|
||||
2. Monitor CI to terminal status:
|
||||
```bash
|
||||
~/.config/mosaic/tools/git/pr-ci-wait.sh -n <PR_NUMBER>
|
||||
@@ -1117,5 +1112,5 @@ If a project currently uses Verdaccio (e.g., U-Connect at `npm.uscllc.net`), fol
|
||||
|
||||
### Pipeline runs Docker builds on pull requests
|
||||
|
||||
- Verify `when` clause on Docker build steps restricts to the integration trunk (`branch: [main]` by default)
|
||||
- Verify `when` clause on Docker build steps restricts to `branch: [main]`
|
||||
- Pull requests should only run quality gates, not build/push images
|
||||
|
||||
@@ -10,10 +10,9 @@ If implementation diverges from `docs/PRD.md` or `docs/PRD.json` without PRD upd
|
||||
|
||||
Merge strategy enforcement (HARD RULE):
|
||||
|
||||
- The integration trunk is the branch the project's `.mosaic/repo.json` declares under `integration_trunk` (default: `main`) — see `CONSTITUTION.md` Hard Gates.
|
||||
- PR target for delivery is the integration trunk.
|
||||
- Direct pushes to the integration trunk are prohibited.
|
||||
- Merge to the integration trunk MUST be squash-only.
|
||||
- PR target for delivery is `main`.
|
||||
- Direct pushes to `main` are prohibited.
|
||||
- Merge to `main` MUST be squash-only.
|
||||
- Use `~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash --expect-head {approved_full_sha}` (or PowerShell equivalent).
|
||||
|
||||
An estate MAY carry a documented exception for a repository whose gates are commit hooks rather
|
||||
@@ -66,19 +65,6 @@ Each of these produced a wrong conclusion before it was written down.
|
||||
conclusion drawn from it describes the wrong tree. Confirm `git rev-parse --show-toplevel`
|
||||
is the tree you think it is before trusting any git output.
|
||||
|
||||
13. **Run the repository's PINNED tool version.** `npx <tool>` resolves a local `node_modules`
|
||||
install when one is present and fetches the latest release when one is not, so the same
|
||||
command answers differently depending on where it ran. A reviewer measuring in a fresh clone
|
||||
or a detached worktree — which is exactly where reviewers measure — has no `node_modules` and
|
||||
silently gets the latest release instead of the pinned one. Measured on mosaicstack#1313: the
|
||||
lockfile pins prettier 3.8.1, under which three guides pass; a version-less `npx` in a
|
||||
worktree resolved 3.9.6, under which the same three fail; and 3.0.0, the floor of the declared
|
||||
`^3.0.0` range, fails a different one. Three versions, three verdicts, identical bytes. Use
|
||||
`node_modules/.bin/<tool>`, or name the version the lockfile pins.
|
||||
14. **A formatter or linter declared as a range is a dated verdict, not a fact.** If a lockfile
|
||||
pins it, the gate is reproducible today and will disagree with itself the day the pin moves.
|
||||
Report a formatting failure with the version that produced it, always.
|
||||
|
||||
### Feedback Categories
|
||||
|
||||
- **Blocker**: must fix before merge (security, bugs, test failures)
|
||||
@@ -198,8 +184,8 @@ Use `~/.config/mosaic/templates/docs/DOCUMENTATION-CHECKLIST.md` whenever code/A
|
||||
# List the issue being addressed
|
||||
~/.config/mosaic/tools/git/issue-list.sh -i {issue-number}
|
||||
|
||||
# View the changes (diff against the integration trunk; default: main)
|
||||
git diff {integration_trunk}...HEAD
|
||||
# View the changes
|
||||
git diff main...HEAD
|
||||
```
|
||||
|
||||
### Providing Feedback
|
||||
@@ -228,4 +214,4 @@ This pattern appears in 3 places. A shared helper would reduce duplication.
|
||||
2. If changes requested, assign back to author
|
||||
3. If approved, note approval in issue comments
|
||||
4. For merges, ensure CI passes first
|
||||
5. Merge PR to the integration trunk with squash strategy only
|
||||
5. Merge PR to `main` with squash strategy only
|
||||
|
||||
@@ -78,7 +78,7 @@ For implementation work, you MUST run this cycle in order:
|
||||
7. `commit` - commit only when the logical unit passes tests and review.
|
||||
8. `pre-push queue guard` - before pushing, wait for running/queued project pipelines to clear: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
|
||||
9. `push` - push immediately after queue guard passes.
|
||||
10. `PR integration` - if external git provider is available, create/update PR to the integration trunk (the project's declared trunk, default `main`) and merge with required strategy via Mosaic wrappers.
|
||||
10. `PR integration` - if external git provider is available, create/update PR to `main` and merge with required strategy via Mosaic wrappers.
|
||||
11. `pre-merge queue guard` - before merging PR, wait for running/queued project pipelines on the exact PR head to clear: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`.
|
||||
12. `CI/pipeline verification` - wait for terminal CI status and require green before completion (`~/.config/mosaic/tools/git/pr-ci-wait.sh` for PR-based workflow).
|
||||
13. `issue closure` - close linked external issue (or close internal `docs/TASKS.md` task ref when provider is unavailable).
|
||||
@@ -199,7 +199,7 @@ Before running this checklist, pause and self-interrogate: did I fulfill the use
|
||||
10. No unresolved blocker hidden.
|
||||
11. If deployment is in scope, deployment target, release version, and post-deploy verification evidence are documented.
|
||||
12. `docs/TASKS.md` status and issue/internal references are updated to match delivered work.
|
||||
13. If source code changed and external provider is available: PR merged to the integration trunk (squash), with merge evidence recorded.
|
||||
13. If source code changed and external provider is available: PR merged to `main` (squash), with merge evidence recorded.
|
||||
14. CI/pipeline status is terminal green for the merged PR/head commit.
|
||||
15. Linked external issue is closed (or internal task ref is closed when no provider exists).
|
||||
16. If any of items 13-15 fail due access/tooling, report `blocked` with exact failed wrapper command and do not claim completion.
|
||||
|
||||
@@ -253,7 +253,7 @@ status → mission → run → repeat
|
||||
|
||||
- [ ] All milestone tasks in TASKS.md are `done`
|
||||
- [ ] CI/pipeline green
|
||||
- [ ] PR merged to the integration trunk
|
||||
- [ ] PR merged to `main`
|
||||
- [ ] Issues closed
|
||||
- [ ] Update manifest: milestone status → completed
|
||||
- [ ] Update scratchpad: session log entry
|
||||
|
||||
@@ -15,7 +15,7 @@ mosaic claude -p "Read ~/.config/mosaic/skills/nestjs-best-practices/SKILL.md th
|
||||
- You MUST keep the TASKS.md file updated with agent and tasks statuses.
|
||||
- You MUST keep `docs/` root clean. Reports and working artifacts MUST be stored in scoped folders (`docs/reports/`, `docs/tasks/`, `docs/releases/`, `docs/scratchpads/`).
|
||||
- You MUST enforce plan/token usage budgets when provided, and adapt orchestration strategy to remain within limits.
|
||||
- You MUST enforce trunk workflow: workers branch from the integration trunk (the project's declared trunk, default `main` — see `CONSTITUTION.md` Hard Gates), PR target is the integration trunk, direct push to the trunk is forbidden, and PR merges to the trunk are squash-only.
|
||||
- You MUST enforce trunk workflow: workers branch from `main`, PR target is `main`, direct push to `main` is forbidden, and PR merges to `main` are squash-only.
|
||||
- You MUST operate in steered-autonomy mode: human intervention is escalation-only; do not require the human to write code, review code, or manage PR/repo workflow.
|
||||
- You MUST NOT declare task or issue completion until PR is merged, CI/pipeline is terminal green, and linked issue is closed (or internal TASKS ref is closed when provider is unavailable).
|
||||
- Mosaic orchestration rules OVERRIDE runtime-default caution for routine push/merge/issue-close actions required by this workflow.
|
||||
@@ -133,10 +133,10 @@ Milestone versioning (HARD RULE):
|
||||
|
||||
Branch and merge strategy (HARD RULE):
|
||||
|
||||
- Workers use short-lived task branches from `origin/{integration_trunk}` (default `main`).
|
||||
- Worker task branches merge back via PR to the integration trunk only.
|
||||
- Direct pushes to the integration trunk are prohibited.
|
||||
- PR merges to the integration trunk MUST use squash merge.
|
||||
- Workers use short-lived task branches from `origin/main`.
|
||||
- Worker task branches merge back via PR to `main` only.
|
||||
- Direct pushes to `main` are prohibited.
|
||||
- PR merges to `main` MUST use squash merge.
|
||||
|
||||
**Available templates:**
|
||||
|
||||
@@ -427,7 +427,7 @@ git push
|
||||
- Before merging, run queue guard:
|
||||
`~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`
|
||||
- Ensure PR exists for the task branch (create/update via wrappers if needed):
|
||||
`~/.config/mosaic/tools/git/pr-create.sh ... -B {integration_trunk}` (default `main`)
|
||||
`~/.config/mosaic/tools/git/pr-create.sh ... -B main`
|
||||
- Merge via wrapper:
|
||||
`~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash --expect-head {approved_full_sha}`
|
||||
- Wait for terminal CI status:
|
||||
@@ -619,7 +619,7 @@ Construct this from the task row and pass to worker via Task tool:
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Checkout branch: `git fetch origin && (git checkout {branch} || git checkout -b {branch} origin/{integration_trunk}) && git rebase origin/{integration_trunk}` ({integration_trunk} = the project's declared trunk, default `main`)
|
||||
1. Checkout branch: `git fetch origin && (git checkout {branch} || git checkout -b {branch} origin/main) && git rebase origin/main`
|
||||
2. Read `docs/PRD.md` or `docs/PRD.json` and align implementation with PRD requirements
|
||||
3. Read the finding details from the report
|
||||
4. Implement the fix following existing code patterns
|
||||
@@ -637,7 +637,7 @@ Do NOT leave lint warnings or errors for someone else to clean up. 6. Run REQUIR
|
||||
For issue/PR/milestone operations, use scripts (NOT raw tea/gh):
|
||||
|
||||
- `~/.config/mosaic/tools/git/issue-view.sh -i {N}`
|
||||
- `~/.config/mosaic/tools/git/pr-create.sh -t "Title" -b "Desc" -B {integration_trunk}`
|
||||
- `~/.config/mosaic/tools/git/pr-create.sh -t "Title" -b "Desc" -B main`
|
||||
- Push: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B {task_branch}`
|
||||
- Merge: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B {pr_head_branch} -R {pr_head_owner/repo} --sha {pr_head_full_sha}`
|
||||
- `~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash --expect-head {approved_full_sha}`
|
||||
@@ -994,13 +994,13 @@ mv docs/reports/qa-automation/pending/*failing-file* docs/reports/qa-automation/
|
||||
|
||||
---
|
||||
|
||||
## Merge-to-Trunk Candidate Protocol (Container Deployments)
|
||||
## Merge-to-Main Candidate Protocol (Container Deployments)
|
||||
|
||||
If deployment is in scope and container images are used, every merge to the integration trunk MUST execute this protocol:
|
||||
If deployment is in scope and container images are used, every merge to `main` MUST execute this protocol:
|
||||
|
||||
1. Build and push immutable candidate image tags:
|
||||
- `sha-<shortsha>` (always)
|
||||
- `v{base-version}-rc.{build}` (for integration-trunk merges)
|
||||
- `v{base-version}-rc.{build}` (for `main` merges)
|
||||
- `testing` mutable pointer to the same digest
|
||||
2. Resolve and record the image digest for each service.
|
||||
3. Deploy by digest to testing environment (never deploy by mutable tag alone).
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -128,7 +128,6 @@ BASE_BRANCH="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print(
|
||||
HEAD_BRANCH="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("headRefName") or "").strip())')"
|
||||
HEAD_SHA="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("headRefOid") or "").strip())')"
|
||||
HEAD_REPO="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; value=json.load(sys.stdin).get("headRepository") or ""; print((value.get("nameWithOwner") or value.get("full_name") or "") if isinstance(value, dict) else str(value).strip())')"
|
||||
BASE_REPO="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; value=json.load(sys.stdin).get("baseRepository") or ""; print((value.get("nameWithOwner") or value.get("full_name") or "") if isinstance(value, dict) else str(value).strip())')"
|
||||
PR_TITLE="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("title") or "").strip())')"
|
||||
PR_AUTHOR="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; value=json.load(sys.stdin).get("author") or ""; print((value.get("login") or "").strip() if isinstance(value, dict) else str(value).strip())')"
|
||||
if [[ "$BASE_BRANCH" != "main" && "$BASE_BRANCH" != "next" ]]; then
|
||||
@@ -145,19 +144,10 @@ if [[ -n "$EXPECT_HEAD" && "$HEAD_SHA" != "$EXPECT_HEAD" ]]; then
|
||||
fi
|
||||
|
||||
if [[ "$DRY_RUN" != true ]]; then
|
||||
# CI statuses for a PR live on the BASE repo (Woodpecker posts there),
|
||||
# even when the head branch lives in a fork. Reading status against the
|
||||
# fork repo yields statuses:null -> malformed for every fork PR (#1215,
|
||||
# gate-merge-01 B1). The head repo is used only for head-sha identity;
|
||||
# when metadata carries no base repository, the origin repo is where CI
|
||||
# posts and remains correct for same-repo PRs.
|
||||
if [[ -z "$BASE_REPO" ]]; then
|
||||
BASE_REPO="$(get_repo_owner)/$(get_repo_name)"
|
||||
fi
|
||||
"$SCRIPT_DIR/ci-queue-wait.sh" \
|
||||
--purpose merge \
|
||||
-B "$HEAD_BRANCH" \
|
||||
-R "$BASE_REPO" \
|
||||
-R "$HEAD_REPO" \
|
||||
--sha "$HEAD_SHA" \
|
||||
-t "${MOSAIC_CI_QUEUE_TIMEOUT_SEC:-900}" \
|
||||
-i "${MOSAIC_CI_QUEUE_POLL_SEC:-15}"
|
||||
|
||||
@@ -209,10 +209,6 @@ base_ref = first_non_empty(
|
||||
data.get('base_ref'),
|
||||
data.get('base_label'),
|
||||
)
|
||||
base_repo = first_non_empty(
|
||||
nested(data, 'base', 'repo', 'full_name'),
|
||||
nested(data, 'base', 'repo', 'name_with_owner'),
|
||||
)
|
||||
|
||||
if not head_ref or not base_ref:
|
||||
available = ', '.join(sorted(data.keys()))
|
||||
@@ -233,7 +229,6 @@ normalized = {
|
||||
'headRefOid': head_sha,
|
||||
'headRepository': head_repo,
|
||||
'baseRefName': base_ref,
|
||||
'baseRepository': base_repo,
|
||||
'labels': [l.get('name', '') for l in data.get('labels', []) if isinstance(l, dict)],
|
||||
'assignees': [a.get('login', '') for a in data.get('assignees', []) if isinstance(a, dict)],
|
||||
'milestone': nested(data, 'milestone', 'title') or '',
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# B1 (stack #1215, gate-merge-01): for a fork PR the merge queue guard must
|
||||
# read CI status against the BASE repository. Woodpecker posts statuses on the
|
||||
# base repo; pr-metadata's headRepository names the fork, and passing it to
|
||||
# ci-queue-wait yields statuses:null -> state=malformed rc=3 on every fork PR.
|
||||
#
|
||||
# This fixture omits baseRepository entirely (the pre-B1 normalizer's shape),
|
||||
# so the guard must fall back to the origin repo — and must NEVER see the fork.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-merge-fork-ci-status}"
|
||||
FIXTURE_DIR="$WORK_DIR/tools/git"
|
||||
CALL_LOG="$WORK_DIR/queue-call.log"
|
||||
|
||||
rm -rf "$WORK_DIR"
|
||||
mkdir -p "$FIXTURE_DIR"
|
||||
cp "$SCRIPT_DIR/pr-merge.sh" "$FIXTURE_DIR/pr-merge.sh"
|
||||
cp "$SCRIPT_DIR/detect-platform.sh" "$FIXTURE_DIR/detect-platform.sh"
|
||||
|
||||
cat > "$FIXTURE_DIR/pr-metadata.sh" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
printf '%s\n' '{"baseRefName":"next","headRefName":"fix/b1-fork-branch","headRefOid":"fedcba9876543210fedcba9876543210fedcba98","headRepository":"stack-mos-dt-0/stack"}'
|
||||
SH
|
||||
|
||||
cat > "$FIXTURE_DIR/ci-queue-wait.sh" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
printf '%s\n' "$*" > "${MOSAIC_QUEUE_CALL_LOG:?}"
|
||||
exit 42
|
||||
SH
|
||||
chmod +x "$FIXTURE_DIR"/*.sh
|
||||
|
||||
# A git repo with an origin remote, so the origin fallback resolves.
|
||||
git init -q "$WORK_DIR/upstream"
|
||||
git -C "$WORK_DIR/upstream" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
|
||||
|
||||
set +e
|
||||
(
|
||||
cd "$WORK_DIR/upstream"
|
||||
export MOSAIC_QUEUE_CALL_LOG="$CALL_LOG"
|
||||
"$FIXTURE_DIR/pr-merge.sh" -n 1215
|
||||
) >/dev/null 2>&1
|
||||
rc=$?
|
||||
set -e
|
||||
|
||||
if [[ "$rc" -ne 42 ]]; then
|
||||
echo "FAIL: expected queue stub rc=42 to propagate, got $rc" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -s "$CALL_LOG" ]]; then
|
||||
echo "FAIL: merge wrapper did not invoke the queue guard" >&2
|
||||
exit 1
|
||||
fi
|
||||
if grep -q -- '-R stack-mos-dt-0/stack' "$CALL_LOG"; then
|
||||
echo "FAIL: queue guard received the FORK repository for CI status (B1 regression)" >&2
|
||||
cat "$CALL_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -q -- '-R mosaicstack/stack' "$CALL_LOG"; then
|
||||
echo "FAIL: queue guard did not receive the base (origin) repository" >&2
|
||||
cat "$CALL_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -q -- '-B fix/b1-fork-branch' "$CALL_LOG"; then
|
||||
echo "FAIL: queue guard did not receive the PR head branch" >&2
|
||||
cat "$CALL_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -q -- '--sha fedcba9876543210fedcba9876543210fedcba98' "$CALL_LOG"; then
|
||||
echo "FAIL: queue guard did not receive the exact PR head SHA" >&2
|
||||
cat "$CALL_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "pr-merge fork-PR CI-status repository regression passed"
|
||||
@@ -15,7 +15,7 @@ cp "$SCRIPT_DIR/detect-platform.sh" "$FIXTURE_DIR/detect-platform.sh"
|
||||
|
||||
cat > "$FIXTURE_DIR/pr-metadata.sh" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
printf '%s\n' '{"baseRefName":"main","baseRepository":"mosaicstack/stack","headRefName":"fix/rm-03-fixture","headRefOid":"0123456789abcdef0123456789abcdef01234567","headRepository":"contributor/widgets-fork"}'
|
||||
printf '%s\n' '{"baseRefName":"main","headRefName":"fix/rm-03-fixture","headRefOid":"0123456789abcdef0123456789abcdef01234567","headRepository":"contributor/widgets-fork"}'
|
||||
SH
|
||||
|
||||
cat > "$FIXTURE_DIR/ci-queue-wait.sh" <<'SH'
|
||||
@@ -52,13 +52,8 @@ if grep -q -- '-B main' "$CALL_LOG"; then
|
||||
cat "$CALL_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -q -- '-R mosaicstack/stack' "$CALL_LOG"; then
|
||||
echo "FAIL: merge queue guard did not receive the BASE repository for CI status" >&2
|
||||
cat "$CALL_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
if grep -q -- '-R contributor/widgets-fork' "$CALL_LOG"; then
|
||||
echo "FAIL: merge queue guard received the fork head repository (B1: statuses are posted on the base repo)" >&2
|
||||
if ! grep -q -- '-R contributor/widgets-fork' "$CALL_LOG"; then
|
||||
echo "FAIL: merge queue guard did not receive the fork head repository" >&2
|
||||
cat "$CALL_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -126,16 +126,7 @@ if [ -z "$SRC_LABEL" ]; then
|
||||
src_host=$(hostname -s 2>/dev/null || echo "?")
|
||||
src_sess=${MOSAIC_AGENT_NAME:-}
|
||||
if [ -z "$src_sess" ]; then
|
||||
if [ -n "${TMUX:-}" ]; then
|
||||
# Inside tmux: display-message resolves against this client's own session.
|
||||
src_sess=$(tmux display-message -p '#S' 2>/dev/null || echo "?")
|
||||
else
|
||||
# Outside tmux with no name: display-message reports the LAST-ACTIVE
|
||||
# session — someone else's identity (measured 2026-08-20: a nameless
|
||||
# non-tmux sender was stamped "peggy", a live seat, forged silently).
|
||||
# Stamp an explicit unverified label instead; deliberate senders use -S.
|
||||
src_sess="unverified"
|
||||
fi
|
||||
src_sess=$(tmux display-message -p '#S' 2>/dev/null || echo "?")
|
||||
fi
|
||||
SRC_LABEL="${src_host}:${src_sess}"
|
||||
fi
|
||||
|
||||
@@ -61,15 +61,8 @@ no() { FAIL=$((FAIL+1)); printf 'FAIL %s\n %s\n' "$1" "$2"; }
|
||||
|
||||
# Run the tool with the stub injected; echoes captured payload on stdout.
|
||||
run() { AGENT_SEND_SENDER="$STUB" bash "$TOOL" -S a:src -n dsthost "$@"; }
|
||||
# Hermetic auto-label runs: TMUX is controlled explicitly so results never
|
||||
# depend on whether the caller running this suite sits inside tmux.
|
||||
run_auto() { # models a sender OUTSIDE tmux (no client context)
|
||||
env -u MOSAIC_AGENT_NAME -u TMUX \
|
||||
AGENT_SEND_SENDER="$STUB" PATH="$FAKE_BIN:$PATH" \
|
||||
bash "$TOOL" -n dsthost "$@"
|
||||
}
|
||||
run_auto_in_tmux() { # models a sender INSIDE tmux (client context exists)
|
||||
env -u MOSAIC_AGENT_NAME TMUX=/fake/socket \
|
||||
run_auto() {
|
||||
env -u MOSAIC_AGENT_NAME \
|
||||
AGENT_SEND_SENDER="$STUB" PATH="$FAKE_BIN:$PATH" \
|
||||
bash "$TOOL" -n dsthost "$@"
|
||||
}
|
||||
@@ -152,9 +145,7 @@ want="[$src_host:authoritative-agent -> dsthost:mos] env identity"
|
||||
|| no "MOSAIC_AGENT_NAME is authoritative across sockets" "got=[$got] want=[$want]"
|
||||
|
||||
# 9. Without the env identity, self-lookup uses local tmux, not destination -L.
|
||||
# Sender is INSIDE tmux: the only context where display-message self-lookup
|
||||
# is safe (it resolves against this client's own session).
|
||||
got=$(FAKE_TMUX_MODE=sessions run_auto_in_tmux -L destination-socket -s mos -m "local fallback")
|
||||
got=$(FAKE_TMUX_MODE=sessions run_auto -L destination-socket -s mos -m "local fallback")
|
||||
want="[$src_host:local-agent -> dsthost:mos] local fallback"
|
||||
[ "$got" = "$want" ] && ok "cross-socket fallback uses local sender session" \
|
||||
|| no "cross-socket fallback uses local sender session" "got=[$got] want=[$want]"
|
||||
@@ -162,17 +153,8 @@ want="[$src_host:local-agent -> dsthost:mos] local fallback"
|
||||
&& ok "cross-socket fallback rejects destination holder identity" \
|
||||
|| no "cross-socket fallback rejects destination holder identity" "got=[$got]"
|
||||
|
||||
# 9b. NO tmux context: display-message answers with the LAST-ACTIVE session —
|
||||
# someone else's identity (forgery vector). The label must be `unverified`,
|
||||
# never a borrowed name, even though a tmux server exists here and the fake
|
||||
# would confidently answer `local-agent`.
|
||||
got=$(FAKE_TMUX_MODE=sessions run_auto -s mos -m "no tmux context")
|
||||
want="[$src_host:unverified -> dsthost:mos] no tmux context"
|
||||
[ "$got" = "$want" ] && ok "no-tmux sender labeled unverified, never borrowed" \
|
||||
|| no "no-tmux sender labeled unverified, never borrowed" "got=[$got] want=[$want]"
|
||||
|
||||
# 10. If neither env nor local tmux identifies the sender, preserve '?'.
|
||||
got=$(FAKE_TMUX_MODE=unavailable run_auto_in_tmux -L destination-socket -s mos -m "unknown fallback")
|
||||
got=$(FAKE_TMUX_MODE=unavailable run_auto -L destination-socket -s mos -m "unknown fallback")
|
||||
want="[$src_host:? -> dsthost:mos] unknown fallback"
|
||||
[ "$got" = "$want" ] && ok "unknown sender falls back to ?" \
|
||||
|| no "unknown sender falls back to ?" "got=[$got] want=[$want]"
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
"lint": "eslint src",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell",
|
||||
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 framework/tools/quality/scripts/test-framework-drift-check.py && bash framework/tools/quality/scripts/test-framework-drift-doctor.sh && bash framework/systemd/user/test-fleet-units.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/promotion_binding_unittest.py && python3 src/lease-broker/promotion_trigger_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/receipt_observer_client_unittest.py && python3 src/lease-broker/invariant_r_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-edit.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-no-status.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-fork-ci-status.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/git/test-explain-diagnostic-status-neutral.sh && bash framework/tools/git/test-detect-platform-outside-repo.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/_scripts/test-mosaic-init-rce.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh && bash framework/tools/glpi/test-list-http-status.sh && bash framework/tools/orchestrator/test-board-roll.sh && bash framework/tools/woodpecker/test-ci-wait-exit-matrix.sh && bash framework/tools/_scripts/test-fleet-transport-check.sh && bash framework/tools/_scripts/test-brain-home-check.sh"
|
||||
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 framework/tools/quality/scripts/test-framework-drift-check.py && bash framework/tools/quality/scripts/test-framework-drift-doctor.sh && bash framework/systemd/user/test-fleet-units.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/promotion_binding_unittest.py && python3 src/lease-broker/promotion_trigger_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/receipt_observer_client_unittest.py && python3 src/lease-broker/invariant_r_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-edit.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-no-status.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/git/test-explain-diagnostic-status-neutral.sh && bash framework/tools/git/test-detect-platform-outside-repo.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/_scripts/test-mosaic-init-rce.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh && bash framework/tools/glpi/test-list-http-status.sh && bash framework/tools/orchestrator/test-board-roll.sh && bash framework/tools/woodpecker/test-ci-wait-exit-matrix.sh && bash framework/tools/_scripts/test-fleet-transport-check.sh && bash framework/tools/_scripts/test-brain-home-check.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mosaicstack/brain": "workspace:*",
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
# 2026-08-17 — Fleet identity, comms delivery, and the ~/.mosaic tree (continuation record)
|
||||
|
||||
> **Status:** active continuation record | **Owner:** Jason (rulings) / fleet (delivery) | **Created:** 2026-08-17, sb-it-1-dt session with Jarvis (jarvis-brain)
|
||||
> **Audience:** the homelab agents continuing this effort tonight. Read this whole file before acting; it supersedes nothing but preserves structure and decisions that must not be lost.
|
||||
|
||||
---
|
||||
|
||||
## Why this exists
|
||||
|
||||
A session on sb-it-1-dt (2026-08-17) produced three architecture decisions (two awaiting Jason's ruling), one incident postmortem (#1295), interim guardrail edits in the user-owned `~/.mosaic/` contract tree, and one new tool (`ensure-watcher.sh`). The work spans jarvis-brain (P0, not retained) and this repo (the product). **This file is the stack-side anchor so continuation does not depend on jarvis-brain surviving.**
|
||||
|
||||
## 1. The `~/.mosaic` tree model — as-built, preserve this structure
|
||||
|
||||
Three-tree split (this is design intent, not accident; keep it through all framework work):
|
||||
|
||||
| Tree | Owner | Rule |
|
||||
| ------------------- | --------- | ------------------------------------------------------------------------------------------------------------- |
|
||||
| `~/.config/mosaic/` | framework | upgrade-managed templates; NEVER user-edited; `mosaic upgrade` may overwrite |
|
||||
| `~/.mosaic/` | user | working contracts, guides, fleet agents; upgrades reconcile with **deny-wins** (user edits never overwritten) |
|
||||
| repo satellites | repos | bootstrapped per-repo `.mosaic/` state |
|
||||
|
||||
As-built inventory of `~/.mosaic` on sb-it-1-dt (2026-08-17):
|
||||
|
||||
- **Contract core:** `CONSTITUTION.md` (L0 law), `AGENTS.md` (dispatcher + guide router + Fleet Comms Watcher requirement), `SOUL.md` (generic base for ALL fleet agents, zero persona — includes the new **Fleet Boundaries** section), `STANDARDS.md` (universal standards — includes new **session identity** + **comms watcher hygiene** sections), `SYSTEM.md` (pure communication contract, byte-identical to jarvis-brain's prompt-testing `sr_opus_5_system_prompt.md`), `USER.md`, `TOOLS.md`.
|
||||
- **`guides/`** — user-owned working copies (E2E-DELIVERY, ORCHESTRATOR(+PROTOCOL,+LEARNINGS), WAKE-DOCTRINE, VAULT-SECRETS, etc.).
|
||||
- **`fleet/agents/`** — the per-agent store (this is MOSAIC-D-002's substrate, already in use):
|
||||
- real agent dirs: `fargo/`, `orchestrator/`, `probe/`, `vision/`, `weekly-update/` — shape: `profile.json` (harness/account/overlay pointer) + `overlay.json` + `SOUL.md` (persona) + `scratch/` `work/` `notes/` subdirs (hygiene rules in root SOUL.md)
|
||||
- `*.env.generated` launch overlays: `luna` `sol` `terra` (carry `MOSAIC_AGENT_NAME`, `_CLASS`, `_RUNTIME`, `_MODEL`, `_REASONING`, `_TOOL_POLICY`) — these are the mosaic-fleet seat launch envs; `inbox.env`, `itops.env` also present
|
||||
- `probe/` is the validated layout proof: auth-bundle symlink chain, per-agent sessions, plugin-store symlink (from 2026-08-07)
|
||||
- **`auth/`, `config/`, `memory/`, `plugins/`, `skills/`, `skills-local/`** — per-tree copies/links for runtime isolation.
|
||||
- Related but outside the tree: watcher units at `~/.config/systemd/user/<agent>-comms-watcher.service`; watcher seen-state at `~/.local/state/comms-watcher-<agent>/`.
|
||||
|
||||
## 2. Decisions register (2026-08-17 session)
|
||||
|
||||
Full strict records live in jarvis-brain `docs/decisions/mosaic-stack/` (render on its dashboard); both are **Pending Jason's ruling**. Summaries so the content survives P0:
|
||||
|
||||
- **MOSAIC-D-001 — SYSTEM.md as canonical harness system prompt.** Static core (Constitution+AGENTS+USER+overlays) in one tracked file; launcher renders dynamic tail (mission/PRD/fleet/persona). Delivery: `--append-system-prompt` (repeatable) for pi/claude; symlinked core file for codex (`$CODEX_HOME/instructions.md`) and opencode (`AGENTS.md`); their dynamic tail via initial prompt (needs live verification). Static-first order is the cache win. `SYSTEM.md` in `~/.mosaic` today is the communication-contract file — D-001's SYSTEM.md is the broader composition; naming to reconcile at implementation.
|
||||
- **MOSAIC-D-002 — per-agent harness homes + mechanical profiles.** Launch with targeted config-dir env vars (e.g. `PI_CODING_AGENT_DIR=~/.mosaic/fleet/agents/<name>/pi`), NOT literal HOME. SOUL.md identity mechanically generated from roster (single writer; kills the hand-copy drift measured in `agents/vision/SOUL.md` on jarvis-brain: declared Jarvis, answered Vision). Composes: SYSTEM core → per-agent SOUL → dynamic tail. `MOSAIC_AGENT_NAME` stays load-bearing for comms.
|
||||
- **Comms delivery tooling belongs in the STACK framework, not jarvis-brain** (decided in discussion; supersedes the interim placement). jarvis-brain keeps only the transport _data_ (`comms/` tree) while it lives. Agents launch from their own repos (terra from `~/src/stack` etc.) — delivery is transport-repo-relative, so this works; but every installed watcher unit's ExecStart currently points into `~/src/jarvis-brain/scripts/` — that dependency is the P0 trap to remove. Migration = move tools + regenerate units, in one step.
|
||||
- **Watcher provisioning is instantiation duty, never running-agent duty.** Interim landed as jarvis-brain `scripts/comms/ensure-watcher.sh` (idempotent ensure + `--status` boot check + interim identity warnings: missing target session, pane `MOSAIC_AGENT_NAME` mismatch via `/proc/<child>/environ`, bare-runtime NOTE). Framework move: fold into `mosaic agent --new` + fleet launch + `mosaic doctor` drift check.
|
||||
- **Prose guardrails landed (interim fences until mechanical fixes):** `~/.mosaic/SOUL.md` Fleet Boundaries (wrong-session tripwire; comms ownership; cross-agent investigation requires tasking) · `STANDARDS.md` session identity + watcher hygiene · `AGENTS.md` Fleet Comms Watcher requirement (P0-interim script path marked transitional).
|
||||
|
||||
## 3. Incident → #1295 (already tracked here)
|
||||
|
||||
`https://git.mosaicstack.dev/mosaicstack/stack/issues/1295` — docs-seat incident: cwd-keyed session files served three lives (dev chat → goals seat → 22 watcher injections into a wedged process); name-based watcher delivery with no identity verification; wedge after pi 0.84.1→0.84.2 update passes every liveness instrument. Proposed fixes enumerated there; provisioning follow-up in comment ID 23027.
|
||||
|
||||
## 4. Open work queue (suggested sequence)
|
||||
|
||||
1. **Comms tooling migration PR** (lane `next`): move `comms-watcher.sh`, `install-watcher.sh`, `ensure-watcher.sh` into the framework tree → deploy `~/.config/mosaic/tools/comms/`; regenerate existing units' ExecStart to framework paths (one-command sweep); keep `COMMS_WATCH_REPO` per-host config (points at a brain checkout until the queue transport lands). Reference: jarvis-brain commit `701c353b1`.
|
||||
2. **Ensure-on-instantiation**: `mosaic agent --new` / fleet launch call ensure semantics; `mosaic doctor` gains the drift check (`--status --all` semantics + `fred`'s hand-written unit as the known drift case; also note daphne/docs/happy/pepper/sanity/tiny/fargo currently have no watcher — cover or consciously exempt).
|
||||
3. **MOSAIC-D-002 implementation** (after ruling): per-agent homes via targeted env vars; roster-generated SOUL.md single-writer; extend the existing `*.env.generated` pattern; launch ledger keeps `config_home` audit.
|
||||
4. **MOSAIC-D-001 implementation** (after ruling): SYSTEM.md sourcing + per-harness delivery + `compose-contract` becomes render-core+tail with drift check; bench cache-ordering before/after (jarvis-brain `domains/software-dev/mosaic-stack/prompt-testing` has the bench).
|
||||
5. **Queue transport + forced separation** (longer term): supersedes watcher path; identity-verification and wedge-detection remain valid regardless of transport.
|
||||
6. **Docs inheritance**: jarvis-brain AGENTS.md's durable comms guidance (E7 pi-glyph delivery gotchas, capture-pane rules, comms protocol) must be inherited into stack docs before P0 retirement.
|
||||
|
||||
## 5. Rules for tonight's agents
|
||||
|
||||
- Lane: **`next`** only; nothing to `main` without Jason (standing ruling).
|
||||
- Attribution caveat #1280: Gitea/git identity from this host may misattribute (issue #1295 showed as created by `@mos-dt-0`); prefer per-invocation `git -c user.name=<seat>` and verify what the remote recorded.
|
||||
- Do not delete `sb-it-1-dt:docs]` (untracked file at repo root) — it is cited fleet-wide as incident evidence.
|
||||
- Edit user contracts in `~/.mosaic/`, never the templates in `~/.config/mosaic/`.
|
||||
- Do not restart other fleet seats unilaterally (goals/scrappy/sanity restart decisions are fred's/Jason's per the docs-seat report).
|
||||
|
||||
## 6. Artifact map
|
||||
|
||||
| Artifact | Where |
|
||||
| ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------ |
|
||||
| Decision records D-001/D-002 (strict, pending ruling) | jarvis-brain `docs/decisions/mosaic-stack/2026-08-17_mosaic-d-00{1,2}_*.md` |
|
||||
| Incident issue + provisioning comment | stack #1295 + comment 23027 |
|
||||
| ensure-watcher.sh (reference implementation) | jarvis-brain `scripts/comms/ensure-watcher.sh` (commit `701c353b1`) |
|
||||
| Bench for prompt A/B (pi, thinking levels, footer-token semantics) | jarvis-brain `domains/software-dev/mosaic-stack/prompt-testing/` + `docs/reports/2026-08-17-prompt-testing-glm-bench.md` |
|
||||
| Launcher inspection basis | `@mosaicstack/mosaic` 0.0.49 `dist/commands/launch.js` (composeContract / ensureRuntimeConfig / harness-home isolation) |
|
||||
| Guardrail edits | `~/.mosaic/{SOUL,AGENTS,STANDARDS}.md` on sb-it-1-dt (2026-08-17 16:53–17:04) |
|
||||
Reference in New Issue
Block a user