Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
923667cc67 |
@@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"integration_trunk": "next",
|
|
||||||
"release_branch": "main"
|
|
||||||
}
|
|
||||||
+5
-35
@@ -21,19 +21,11 @@ variables:
|
|||||||
- &enable_pnpm 'corepack enable'
|
- &enable_pnpm 'corepack enable'
|
||||||
|
|
||||||
when:
|
when:
|
||||||
# PR + manual CI run on any branch: the pull_request pipeline is the merge
|
# PR + manual CI run on any branch — the pull_request pipeline is the merge gate.
|
||||||
# gate (next is protected and the default branch since 2026-08-19).
|
# push CI is restricted to protected branches (main) so a feature-branch push no
|
||||||
# Push CI runs on main only. next deliberately runs NO push ci: post-merge
|
# longer fires a redundant SECOND pipeline alongside its PR pipeline. This ~halves
|
||||||
# verification on next is carried by publish.yml's `verify` step
|
# CI load on the storage-constrained runner with zero loss of gating (branch
|
||||||
# (pnpm verify:release), which mirrors this pipeline's complete mandatory
|
# protection requires no push/ci status context; main still gets full push CI).
|
||||||
# set step-for-step, enforced by scripts/verify-release.test.mjs. PR CI
|
|
||||||
# tests the PR HEAD tree (refs/pull/N/head, measured 2026-08-19), not a
|
|
||||||
# merge ref, so if next advances before a merge the landed tree differs
|
|
||||||
# from the tested one; publish verify re-runs the full set on the landed
|
|
||||||
# tree (PGlite path). Measured 2026-08-19: the 21 most recent push events
|
|
||||||
# on next each ran exactly one pipeline (publish), zero ci.
|
|
||||||
# Keeping push ci off next also avoids a redundant second full-suite run
|
|
||||||
# per merge on the storage-constrained runner.
|
|
||||||
- event: [pull_request, manual]
|
- event: [pull_request, manual]
|
||||||
- event: push
|
- event: push
|
||||||
branch: main
|
branch: main
|
||||||
@@ -81,28 +73,6 @@ steps:
|
|||||||
# [0] of the pnpm chain, so severing that chain would silence it together
|
# [0] of the pnpm chain, so severing that chain would silence it together
|
||||||
# with everything it guards; this direct line keeps one instrument running.
|
# with everything it guards; this direct line keeps one instrument running.
|
||||||
- bash packages/mosaic/framework/tools/quality/scripts/check-test-enumeration.sh
|
- bash packages/mosaic/framework/tools/quality/scripts/check-test-enumeration.sh
|
||||||
# Tool-index gate: a shipped wrapper that appears in no resident index doc
|
|
||||||
# is undiscoverable from inside a session, and an agent that cannot learn a
|
|
||||||
# wrapper exists reaches for raw curl instead — which is how a Gitea review
|
|
||||||
# got filed PENDING three times. Ships-and-documented is one commit, or red.
|
|
||||||
- bash packages/mosaic/framework/tools/quality/scripts/check-tools-index.sh --self-test
|
|
||||||
- bash packages/mosaic/framework/tools/quality/scripts/check-tools-index.sh
|
|
||||||
# Hermetic regression for issue-close.sh (#1081): mocks tea/curl onto PATH
|
|
||||||
# and sandboxes a throwaway git repo, so it resolves no real credentials and
|
|
||||||
# joins CI directly rather than the exclusions file.
|
|
||||||
- bash packages/mosaic/framework/tools/git/test-issue-close-fail-closed.sh
|
|
||||||
# Hermetic behavioural regression for the PreToolUse wrapper guard: proves
|
|
||||||
# it still blocks the three mistakes AND still lets reads, unwrapped
|
|
||||||
# endpoints and ordinary commands through. Both directions are asserted —
|
|
||||||
# a guard that over-blocks gets routed around, which fails just as hard.
|
|
||||||
- bash packages/mosaic/framework/tools/git/test-wrapper-guard.sh
|
|
||||||
# Hermetic regression for mosaic-worktree.sh at fleet scale: stubs git onto
|
|
||||||
# PATH so `list` faces ~450 KB of porcelain. The defect it pins is invisible
|
|
||||||
# at small size — `git … | awk '…exit'` gives the producer SIGPIPE, which
|
|
||||||
# under `set -euo pipefail` aborts the caller silently with rc=141 and no
|
|
||||||
# output. A repo only reaches that once it has enough worktrees, so the
|
|
||||||
# stub supplies the scale instead of the host's own checkout.
|
|
||||||
- bash packages/mosaic/framework/tools/git/test-mosaic-worktree-large-repo.sh
|
|
||||||
|
|
||||||
# Canonical verify:release stage `upgrade-guard`.
|
# Canonical verify:release stage `upgrade-guard`.
|
||||||
# Blocking gate (#791): a framework upgrade must never write or delete an
|
# Blocking gate (#791): a framework upgrade must never write or delete an
|
||||||
|
|||||||
@@ -81,73 +81,6 @@ pnpm format:check # Prettier check
|
|||||||
pnpm build # Build all packages and applications
|
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
|
## Database and Local Runtime Safety
|
||||||
|
|
||||||
- Current local data-layer work uses in-process PGlite; leave `DATABASE_URL` unset.
|
- Current local data-layer work uses in-process PGlite; leave `DATABASE_URL` unset.
|
||||||
|
|||||||
@@ -74,14 +74,6 @@ The launcher verifies your config, checks for `SOUL.md`, injects your `AGENTS.md
|
|||||||
|
|
||||||
Pi launches default to a token-lean skill posture: `mosaic pi` passes `--no-skills` so Pi does not preload every global skill description into the system prompt. Use `MOSAIC_PI_SKILL_MODE=all mosaic pi` for the legacy all-skills catalog, or `MOSAIC_PI_SKILL_MODE=discover mosaic pi` to let Pi use its native settings/project skill discovery.
|
Pi launches default to a token-lean skill posture: `mosaic pi` passes `--no-skills` so Pi does not preload every global skill description into the system prompt. Use `MOSAIC_PI_SKILL_MODE=all mosaic pi` for the legacy all-skills catalog, or `MOSAIC_PI_SKILL_MODE=discover mosaic pi` to let Pi use its native settings/project skill discovery.
|
||||||
|
|
||||||
Mosaic also loads its Pi extensions from `~/.config/mosaic/runtime/pi/`. Inside Pi,
|
|
||||||
`/goal set <statement>` starts a bounded persistent loop that checks every turn and successful
|
|
||||||
compaction, requires two evidence-bearing completion reports, and can be inspected or stopped with
|
|
||||||
`/goal status`, `/goal pause`, `/goal resume`, and `/goal cancel`. Controller-owned goal-state
|
|
||||||
entries redact common credential shapes, but Pi's model/tool-call history is separate, so goals and
|
|
||||||
evidence must never contain secrets or raw sensitive output. Mosaic does not install this extension
|
|
||||||
into `~/.pi/agent/extensions/`.
|
|
||||||
|
|
||||||
### TUI & Gateway
|
### TUI & Gateway
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -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,
|
{} as never,
|
||||||
{ getToolDefinitions: vi.fn(() => []) } as never,
|
{ getToolDefinitions: vi.fn(() => []) } as never,
|
||||||
{ loadForSession: vi.fn(async () => ({ metaTools: [], promptAdditions: [] })) } as never,
|
{ loadForSession: vi.fn(async () => ({ metaTools: [], promptAdditions: [] })) } as never,
|
||||||
{ get: vi.fn().mockResolvedValue(null), renew: vi.fn().mockResolvedValue(undefined) } as never,
|
null,
|
||||||
null,
|
null,
|
||||||
{ collect: vi.fn().mockResolvedValue(undefined) } as never,
|
{ collect: vi.fn().mockResolvedValue(undefined) } as never,
|
||||||
operatorMemory as never,
|
operatorMemory as never,
|
||||||
|
|||||||
@@ -132,8 +132,9 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
@Inject(CoordService) private readonly coordService: CoordService,
|
@Inject(CoordService) private readonly coordService: CoordService,
|
||||||
@Inject(McpClientService) private readonly mcpClientService: McpClientService,
|
@Inject(McpClientService) private readonly mcpClientService: McpClientService,
|
||||||
@Inject(SkillLoaderService) private readonly skillLoaderService: SkillLoaderService,
|
@Inject(SkillLoaderService) private readonly skillLoaderService: SkillLoaderService,
|
||||||
|
@Optional()
|
||||||
@Inject(SystemOverrideService)
|
@Inject(SystemOverrideService)
|
||||||
private readonly systemOverride: SystemOverrideService,
|
private readonly systemOverride: SystemOverrideService | null,
|
||||||
@Optional()
|
@Optional()
|
||||||
@Inject(PreferencesService)
|
@Inject(PreferencesService)
|
||||||
private readonly preferencesService: PreferencesService | null,
|
private readonly preferencesService: PreferencesService | null,
|
||||||
@@ -708,22 +709,23 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
throw new Error(`No agent session found: ${sessionId}`);
|
throw new Error(`No agent session found: ${sessionId}`);
|
||||||
}
|
}
|
||||||
this.assertSessionScope(session, scope);
|
this.assertSessionScope(session, scope);
|
||||||
|
session.promptCount += 1;
|
||||||
|
|
||||||
// Channel attachments are untrusted URI references. Preserve exact,
|
// Channel attachments are untrusted URI references. Preserve exact,
|
||||||
// authenticated metadata for the agent without treating it as authority.
|
// authenticated metadata for the agent without treating it as authority.
|
||||||
const attachmentContext = this.attachmentContext(attachments);
|
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}`;
|
let effectiveMessage = `${message}${attachmentContext}`;
|
||||||
const override = await this.systemOverride.get(sessionId, scope);
|
if (this.systemOverride) {
|
||||||
if (override) {
|
const override = await this.systemOverride.get(sessionId, scope);
|
||||||
effectiveMessage = `[System Override]\n${override}\n\n${effectiveMessage}`;
|
if (override) {
|
||||||
await this.systemOverride.renew(sessionId, scope);
|
effectiveMessage = `[System Override]\n${override}\n\n${effectiveMessage}`;
|
||||||
this.logger.debug(`Applied system override for session ${sessionId}`);
|
await this.systemOverride.renew(sessionId, scope);
|
||||||
|
this.logger.debug(`Applied system override for session ${sessionId}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
session.promptCount += 1;
|
|
||||||
try {
|
try {
|
||||||
await session.piSession.prompt(effectiveMessage);
|
await session.piSession.prompt(effectiveMessage);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -80,10 +80,6 @@ const mockMcpClient = {
|
|||||||
getToolDefinitions: vi.fn(() => []),
|
getToolDefinitions: vi.fn(() => []),
|
||||||
};
|
};
|
||||||
|
|
||||||
const allowAuthorization = {
|
|
||||||
authorize: vi.fn().mockResolvedValue({ allowed: true }),
|
|
||||||
};
|
|
||||||
|
|
||||||
function buildService(
|
function buildService(
|
||||||
redis: typeof mockRedis | null = mockRedis,
|
redis: typeof mockRedis | null = mockRedis,
|
||||||
mcpClient: {
|
mcpClient: {
|
||||||
@@ -102,7 +98,6 @@ function buildService(
|
|||||||
null,
|
null,
|
||||||
mockChatGateway as never,
|
mockChatGateway as never,
|
||||||
mcpClient as never,
|
mcpClient as never,
|
||||||
allowAuthorization as never,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,8 +35,9 @@ export class CommandExecutorService {
|
|||||||
@Inject(forwardRef(() => ChatGateway))
|
@Inject(forwardRef(() => ChatGateway))
|
||||||
private readonly chatGateway: ChatGateway | null,
|
private readonly chatGateway: ChatGateway | null,
|
||||||
@Inject(McpClientService) private readonly mcpClient: McpClientService,
|
@Inject(McpClientService) private readonly mcpClient: McpClientService,
|
||||||
|
@Optional()
|
||||||
@Inject(CommandAuthorizationService)
|
@Inject(CommandAuthorizationService)
|
||||||
private readonly authorization: CommandAuthorizationService,
|
private readonly authorization: CommandAuthorizationService | null = null,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(
|
async execute(
|
||||||
@@ -56,13 +57,13 @@ export class CommandExecutorService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const authorization = await this.authorization.authorize(
|
const authorization = await this.authorization?.authorize(
|
||||||
def,
|
def,
|
||||||
payload,
|
payload,
|
||||||
userId,
|
userId,
|
||||||
payload.approvalId,
|
payload.approvalId,
|
||||||
);
|
);
|
||||||
if (!authorization.allowed) {
|
if (authorization && !authorization.allowed) {
|
||||||
return { command, conversationId, success: false, message: authorization.reason };
|
return { command, conversationId, success: false, message: authorization.reason };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,7 +171,7 @@ export class CommandExecutorService {
|
|||||||
const def = this.registry
|
const def = this.registry
|
||||||
.getManifest()
|
.getManifest()
|
||||||
.commands.find((command) => command.name === payload.command);
|
.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);
|
return this.authorization.createApproval(def, payload, scope.userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -55,10 +55,6 @@ const mockMcpClient = {
|
|||||||
reconnectServer: vi.fn().mockResolvedValue(undefined),
|
reconnectServer: vi.fn().mockResolvedValue(undefined),
|
||||||
};
|
};
|
||||||
|
|
||||||
const allowAuthorization = {
|
|
||||||
authorize: vi.fn().mockResolvedValue({ allowed: true }),
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function buildRegistry(): CommandRegistryService {
|
function buildRegistry(): CommandRegistryService {
|
||||||
@@ -78,7 +74,6 @@ function buildExecutor(registry: CommandRegistryService): CommandExecutorService
|
|||||||
null, // reloadService (optional)
|
null, // reloadService (optional)
|
||||||
null, // chatGateway (optional)
|
null, // chatGateway (optional)
|
||||||
mockMcpClient as never,
|
mockMcpClient as never,
|
||||||
allowAuthorization as never,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -245,21 +245,9 @@ describe('EnrollmentService.createToken', () => {
|
|||||||
const after = Date.now();
|
const after = Date.now();
|
||||||
|
|
||||||
const expiresMs = new Date(result.expiresAt).getTime();
|
const expiresMs = new Date(result.expiresAt).getTime();
|
||||||
|
// Should be at most 900s from now
|
||||||
// The property under test is CLAMPING: a 9999s request must come back as 900s.
|
expect(expiresMs - before).toBeLessThanOrEqual(900_000 + 100);
|
||||||
// The gap between clamped and unclamped is 9_099_000 ms, so the tolerance below
|
|
||||||
// only has to exceed CI scheduling jitter — it does not need to be tight to keep
|
|
||||||
// the assertion discriminating. A 5s allowance consumes 0.05% of that margin and
|
|
||||||
// an unclamped result still misses by three orders of magnitude.
|
|
||||||
//
|
|
||||||
// It was 100ms and failed on a loaded agent at 900_106 — 6ms over (#1090). A
|
|
||||||
// wall-clock budget sized to a fast machine is a flake, not a tighter test.
|
|
||||||
const CI_JITTER_MS = 5_000;
|
|
||||||
expect(expiresMs - before).toBeLessThanOrEqual(900_000 + CI_JITTER_MS);
|
|
||||||
expect(expiresMs - after).toBeGreaterThanOrEqual(0);
|
expect(expiresMs - after).toBeGreaterThanOrEqual(0);
|
||||||
// Explicitly pin the clamp itself, independent of any timing allowance:
|
|
||||||
// unclamped (9999s) would exceed this by ~9_099_000 ms.
|
|
||||||
expect(expiresMs - before).toBeLessThan(1_000_000);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -159,7 +159,6 @@ describe('ReloadService — /reload command sanitizes plugin errors', () => {
|
|||||||
reloadService,
|
reloadService,
|
||||||
mockChatGateway as never,
|
mockChatGateway as never,
|
||||||
mockMcpClient as never,
|
mockMcpClient as never,
|
||||||
{ authorize: vi.fn().mockResolvedValue({ allowed: true }) } as never,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const payload: SlashCommandPayload = { command: 'reload', conversationId: 'conv-1' };
|
const payload: SlashCommandPayload = { command: 'reload', conversationId: 'conv-1' };
|
||||||
|
|||||||
-246
@@ -1,14 +1,5 @@
|
|||||||
# PRD: Mosaic Stack v0.1.0
|
# PRD: Mosaic Stack v0.1.0
|
||||||
|
|
||||||
## Current addendum: #1194 — Installed framework-tool drift detection
|
|
||||||
|
|
||||||
- Compare the framework tools shipped with the executing Mosaic package against the deployed `$MOSAIC_HOME/tools` tree by content hash.
|
|
||||||
- Treat every shipped `tools/**` file as framework-owned/required according to `framework-manifest.txt`, while excluding the explicit operator-owned credential carve-out and preserving installed-only operator/unknown files.
|
|
||||||
- Distinguish and count `IN_SYNC`, `STALE`, `NOT_INSTALLED`, and installed-only classifications; fail non-zero when shipped tools are stale or absent and refuse self-comparison that would make drift unobservable.
|
|
||||||
- Surface the observational check through `mosaic doctor`; do not refresh files, restart seats, or mutate live tooling.
|
|
||||||
- Document identity/messaging/gate behavior changes in the current stale set, the reviewed quiet-window keep-mode refresh command, and post-refresh probes against the installed path.
|
|
||||||
- Prove by construction that a stale and missing deployed tool are detected; that regression must fail before this checker exists.
|
|
||||||
|
|
||||||
## Metadata
|
## Metadata
|
||||||
|
|
||||||
- **Owner:** Jason Woltje
|
- **Owner:** Jason Woltje
|
||||||
@@ -111,128 +102,6 @@ Context compaction, session replacement, and same-PID runtime reloads can leave
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Pi Persistent Goal Loop (#1150)
|
|
||||||
|
|
||||||
### Problem and objective
|
|
||||||
|
|
||||||
A Pi agent can stop after a plausible-looking answer even when the operator's broader objective is
|
|
||||||
not complete, and ordinary compaction can weaken or omit the original objective. Mosaic needs an
|
|
||||||
optional, operator-controlled goal loop that keeps a Pi session oriented, checks progress at native
|
|
||||||
lifecycle boundaries, and resumes work until completion is verified or a bounded safety state is
|
|
||||||
reached.
|
|
||||||
|
|
||||||
The objective is a Mosaic-owned Pi extension deployed from the framework into
|
|
||||||
`~/.config/mosaic/runtime/pi/`. It must not install into or depend on `~/.pi/agent/extensions/`.
|
|
||||||
|
|
||||||
### Scope
|
|
||||||
|
|
||||||
#### In scope
|
|
||||||
|
|
||||||
1. `PGL-REQ-01`: The framework SHALL ship a dedicated Pi goal extension under
|
|
||||||
`packages/mosaic/framework/runtime/pi/`, seed it under `$MOSAIC_HOME/runtime/pi/`, and make
|
|
||||||
`mosaic pi` load it alongside the core Mosaic extension when present.
|
|
||||||
2. `PGL-REQ-02`: `/goal` SHALL support setting a goal plus status, pause, resume, cancel, and help
|
|
||||||
operations without silently replacing an active goal.
|
|
||||||
3. `PGL-REQ-03`: Active branch-specific goal state SHALL be persisted in Pi custom session entries,
|
|
||||||
restored on session start and tree navigation, and never rely on a compaction summary as its
|
|
||||||
source of truth.
|
|
||||||
4. `PGL-REQ-04`: A hidden goal contract SHALL be injected through Pi's `context` event before every
|
|
||||||
model request so it remains effective across tool turns, retries, and post-compaction requests.
|
|
||||||
5. `PGL-REQ-05`: The harness SHALL inspect every `turn_end` and successful `session_compact` event.
|
|
||||||
A structured terminating goal-report tool SHALL capture `continue`, evidence-bearing `achieved`,
|
|
||||||
or `blocked` status without requiring a redundant model turn.
|
|
||||||
6. `PGL-REQ-06`: An achievement claim SHALL remain provisional until a second consecutive
|
|
||||||
evidence-bearing verification report. Any continuation report or successful compaction during
|
|
||||||
verification SHALL reset the verification sequence.
|
|
||||||
7. `PGL-REQ-07`: Continuation SHALL be initiated at safe lifecycle boundaries, primarily
|
|
||||||
`agent_settled`; manual compaction and restored active sessions may schedule a deferred idle
|
|
||||||
continuation without re-entering compaction handlers.
|
|
||||||
8. `PGL-REQ-08`: The loop SHALL have operator cancellation plus bounded turn and repeated-no-progress
|
|
||||||
limits. Exhausted or blocked goals pause rather than continuing indefinitely.
|
|
||||||
9. `PGL-REQ-09`: Framework installation and update SHALL preserve normal manifest ownership: the
|
|
||||||
goal extension is framework-owned under `runtime/**`, while no goal extension or configuration
|
|
||||||
asset is created or modified under the operator's main Pi configuration. Pi remains the owner of
|
|
||||||
its native session files used by `appendEntry()`.
|
|
||||||
|
|
||||||
#### Out of scope
|
|
||||||
|
|
||||||
1. A mathematical guarantee that an arbitrary natural-language goal is semantically complete.
|
|
||||||
2. Automatically executing user-supplied shell predicates or accepting executable validation code in
|
|
||||||
`/goal` arguments.
|
|
||||||
3. Restarting Pi after process, host, or supervisor failure; the existing Mosaic fleet/runtime
|
|
||||||
supervisor owns process durability.
|
|
||||||
4. Gateway, database, web UI, Discord, or cross-harness goal orchestration in this slice.
|
|
||||||
|
|
||||||
### User and stakeholder requirements
|
|
||||||
|
|
||||||
- An operator can start a goal from Pi and see its current phase, evidence, limits, and latest report.
|
|
||||||
- The agent remains oriented after each turn and compaction until verified, paused, blocked,
|
|
||||||
exhausted, or cancelled.
|
|
||||||
- Local testing uses a file under `~/.config/mosaic/runtime/pi/`; the feature never writes an
|
|
||||||
extension asset to `~/.pi/agent/extensions/`.
|
|
||||||
- Framework updates deploy the same reviewed extension source through Mosaic's existing manifest
|
|
||||||
sync path.
|
|
||||||
|
|
||||||
### Non-functional requirements
|
|
||||||
|
|
||||||
1. **Safety:** bounded continuation, explicit cancellation, no arbitrary command execution, and no
|
|
||||||
completion without non-empty reported evidence.
|
|
||||||
2. **Reliability:** serialized continuation scheduling, branch-aware restoration, compaction-safe
|
|
||||||
context injection, and stale-timer cancellation on session shutdown.
|
|
||||||
3. **Performance:** no extra nested judge-model request on every turn; structured reporting uses the
|
|
||||||
active agent's final terminating tool call.
|
|
||||||
4. **Observability:** Pi status/notifications expose phase and bounded counters without recording
|
|
||||||
credentials or hidden model reasoning.
|
|
||||||
5. **Maintainability:** the state machine is deterministic and behavior-tested independently from Pi
|
|
||||||
provider/network access.
|
|
||||||
|
|
||||||
### Acceptance criteria
|
|
||||||
|
|
||||||
1. `AC-PGL-01`: A framework-sync fixture installs the extension at
|
|
||||||
`$MOSAIC_HOME/runtime/pi/goal-extension.ts`, and launcher tests prove both Mosaic Pi extensions are
|
|
||||||
emitted in deterministic order while absent optional files remain backward-compatible.
|
|
||||||
2. `AC-PGL-02`: Command tests prove set/status/pause/resume/cancel behavior, active-goal replacement
|
|
||||||
refusal, and bounded input handling.
|
|
||||||
3. `AC-PGL-03`: Lifecycle tests prove every turn is recorded, active context is injected on every
|
|
||||||
request, two evidence-bearing achievement reports are required, and `agent_settled` continues an
|
|
||||||
unmet goal without duplicate scheduling.
|
|
||||||
4. `AC-PGL-04`: Compaction and restoration tests prove goal state survives, verification is reset and
|
|
||||||
rechecked after compaction, manual compaction continuation is deferred until idle, and tree/session
|
|
||||||
branch state is reconstructed correctly.
|
|
||||||
5. `AC-PGL-05`: Limit tests prove max-turn and repeated-no-progress exhaustion stop autonomous
|
|
||||||
continuation, while pause/cancel/blocked states do not restart.
|
|
||||||
6. `AC-PGL-06`: Focused tests, package typecheck/lint/test, repository quality gates, a local Pi load
|
|
||||||
smoke test from `~/.config/mosaic/runtime/pi/`, independent review, and terminal-green CI pass before
|
|
||||||
issue #1150 closes.
|
|
||||||
|
|
||||||
### Constraints, risks, and assumptions
|
|
||||||
|
|
||||||
- Dependency: Pi's extension API must continue to provide `registerCommand`, `registerTool`,
|
|
||||||
`context`, `turn_end`, `agent_settled`, `session_compact`, session custom entries, and terminating
|
|
||||||
tool results.
|
|
||||||
- Risk: the working agent can overstate completion. Mitigation: structured evidence, a mandatory
|
|
||||||
second verification pass, explicit semantic limitations, and operator-visible reports.
|
|
||||||
- Risk: an impossible goal can consume unbounded resources. Mitigation: hard turn/no-progress bounds
|
|
||||||
and paused terminal states.
|
|
||||||
- Risk: automatic continuation can race compaction or session replacement. Mitigation: drive from
|
|
||||||
`agent_settled`, defer idle restarts, generation-check timers, and clear timers on shutdown.
|
|
||||||
- `ASSUMPTION:` Two consecutive evidence-bearing reports are the initial local verification policy;
|
|
||||||
rationale: it provides a real recheck without doubling every turn's model cost. Future policy may
|
|
||||||
add independent or deterministic validators.
|
|
||||||
- `ASSUMPTION:` Default limits are 40 turns and 6 repeated no-progress reports, configurable only by
|
|
||||||
bounded Mosaic environment settings; rationale: useful persistence with a finite autonomous budget.
|
|
||||||
- `ASSUMPTION:` Documentation remains canonical in-repo for this slice; no external docs publication
|
|
||||||
is requested.
|
|
||||||
|
|
||||||
### Testing and delivery intent
|
|
||||||
|
|
||||||
Use TDD for the deterministic controller and lifecycle invariants. Test with fake Pi lifecycle
|
|
||||||
objects first, then run a local load/smoke test from the deployed Mosaic path. Deliver source, tests,
|
|
||||||
launcher wiring, framework/runtime documentation, user/developer guides, and sitemap updates in one
|
|
||||||
reviewed squash PR to `main` with terminal-green CI.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Fleet Declarative Configuration Management Workstream (FCM, #758)
|
## Fleet Declarative Configuration Management Workstream (FCM, #758)
|
||||||
|
|
||||||
### Problem and objective
|
### Problem and objective
|
||||||
@@ -277,68 +146,6 @@ lands. M0 consists only of these normative requirements, the complete task DAG,
|
|||||||
documentation IA checklist, and the legacy example/profile disposition inventory. Subsequent cards
|
documentation IA checklist, and the legacy example/profile disposition inventory. Subsequent cards
|
||||||
are defined in [docs/TASKS.md](./TASKS.md) and must remain one card/one PR.
|
are defined in [docs/TASKS.md](./TASKS.md) and must remain one card/one PR.
|
||||||
|
|
||||||
### Fleet git identity launch propagation (#1043)
|
|
||||||
|
|
||||||
#### Problem and objective
|
|
||||||
|
|
||||||
A fleet seat can have a registered per-agent Git credential while its launched runtime process lacks
|
|
||||||
`MOSAIC_GIT_IDENTITY`. The credential resolver then cannot select the seat identity reliably, which
|
|
||||||
blocks repository operations on fail-closed estates and can fall through to an unrelated identity on
|
|
||||||
estates where that refusal is not active. The objective is to make Git identity a deterministic,
|
|
||||||
roster-derived part of the generated launch projection and prove it reaches the launched process.
|
|
||||||
|
|
||||||
#### Normative requirements
|
|
||||||
|
|
||||||
1. `FGI-REQ-01`: Every generated fleet agent projection SHALL declare
|
|
||||||
`MOSAIC_GIT_IDENTITY=<MOSAIC_AGENT_NAME>`; a differing or unsafe identity SHALL fail closed before
|
|
||||||
tmux launch.
|
|
||||||
2. `FGI-REQ-02`: The clean `/usr/bin/env -i` pane boundary SHALL pass every variable declared by the
|
|
||||||
generated projection, including `MOSAIC_GIT_IDENTITY`, to the launched runtime process.
|
|
||||||
3. `FGI-REQ-03`: A behavioral integration test SHALL set-compare the complete generated projection
|
|
||||||
against the launched process environment. Source-text/string-presence assertions are insufficient.
|
|
||||||
4. `FGI-REQ-04`: Verification SHALL include RED-first evidence and a delete-the-subject mutation that
|
|
||||||
removes Git-identity pane propagation and makes the behavioral test fail.
|
|
||||||
|
|
||||||
#### Acceptance criteria
|
|
||||||
|
|
||||||
1. `AC-FGI-01`: A launched seat process contains every key/value pair declared by its generated
|
|
||||||
environment projection, including the roster-derived Git identity.
|
|
||||||
2. `AC-FGI-02`: Missing, unsafe, or split Git identity is rejected before a tmux session is created.
|
|
||||||
3. `AC-FGI-03`: Focused launcher and generated-environment tests, repository quality gates,
|
|
||||||
independent review, and the required RED/green/R7 evidence are recorded before push.
|
|
||||||
|
|
||||||
### Framework shell assertion portability (#1098)
|
|
||||||
|
|
||||||
#### Problem and objective
|
|
||||||
|
|
||||||
The blocking framework-shell chain can report that a pane command omitted `/usr/bin/env -i` even when
|
|
||||||
`-i` matched successfully. A short-circuiting `grep -q` under `set -o pipefail` may close its pipe after
|
|
||||||
the match and cause an upstream producer to exit with SIGPIPE, turning a valid semantic result into a
|
|
||||||
nonzero aggregate pipeline. The objective is to inspect the captured NUL-delimited argv directly and
|
|
||||||
make failures carry the observed records needed for diagnosis.
|
|
||||||
|
|
||||||
#### Normative requirements
|
|
||||||
|
|
||||||
1. `FSP-REQ-01`: The pane-boundary test SHALL validate an adjacent `/usr/bin/env`, `-i` argv pair from
|
|
||||||
the authoritative NUL-delimited tmux capture without a short-circuit pipeline whose upstream status
|
|
||||||
can override a successful match.
|
|
||||||
2. `FSP-REQ-02`: Missing, reversed, or non-adjacent boundary tokens SHALL fail, while valid boundaries
|
|
||||||
SHALL remain valid regardless of trailing argv size, pipe capacity, process scheduling, or host/CI
|
|
||||||
utility implementation.
|
|
||||||
3. `FSP-REQ-03`: A failed boundary check SHALL print stable indexed, shell-escaped observed argv records
|
|
||||||
before exiting nonzero; the fixture SHALL continue to contain generated non-secret launch data only.
|
|
||||||
4. `FSP-REQ-04`: Verification SHALL include RED-first large-payload evidence, negative token-order
|
|
||||||
controls, the complete focused launcher suite, canonical Woodpecker CI, and independent review.
|
|
||||||
|
|
||||||
#### Acceptance criteria
|
|
||||||
|
|
||||||
1. `AC-FSP-01`: A large captured argv with adjacent `/usr/bin/env`, `-i` passes even when the former
|
|
||||||
`grep -q` pipeline returns nonzero from an upstream SIGPIPE.
|
|
||||||
2. `AC-FSP-02`: Missing executable, missing flag, and detached/reversed flag fixtures return nonzero and
|
|
||||||
emit the indexed observed argv.
|
|
||||||
3. `AC-FSP-03`: The focused suite passes on the development host and CI image, and the merged-main
|
|
||||||
Woodpecker pipeline is terminal green before #1098 closes.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Exact Cross-Harness Fleet Communications Contract (#766)
|
## Exact Cross-Harness Fleet Communications Contract (#766)
|
||||||
@@ -1538,59 +1345,6 @@ All work is **alpha** (< 0.1.0) until Jason approves 0.1.0 beta release.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Workspace placement guard hardening (#1174)
|
|
||||||
|
|
||||||
### Problem and objective
|
|
||||||
|
|
||||||
The Bash pre-tool guard must prevent Git checkouts and repository state from being placed under
|
|
||||||
`$HOME` without refusing ordinary Git commands merely because a source, option value, branch name,
|
|
||||||
or metadata mentions `$HOME`. A guard that over-blocks routine work is unsafe because operators
|
|
||||||
will route around it.
|
|
||||||
|
|
||||||
### Scope and requirements
|
|
||||||
|
|
||||||
1. `WPG-REQ-01`: `git clone` and `git worktree add` placement SHALL be judged from their placement
|
|
||||||
operands, not from every HOME-shaped word in the command.
|
|
||||||
2. `WPG-REQ-02`: Clone sources, references, templates, environment assignments, and non-placement
|
|
||||||
worktree metadata MAY resolve under HOME when all placement operands resolve elsewhere.
|
|
||||||
3. `WPG-REQ-03`: Both attached and separate-value `--separate-git-dir` forms SHALL remain placement
|
|
||||||
operands and SHALL be refused when they resolve under HOME.
|
|
||||||
4. `WPG-REQ-04`: Option classification SHALL account for Git's rule-generated boolean negations
|
|
||||||
without relying on an enumerable allowlist of flag spellings.
|
|
||||||
5. `WPG-REQ-05`: Quote removal, escapes, shell command boundaries, redirections, and end-of-options
|
|
||||||
handling SHALL preserve existing fail-closed checkout coverage.
|
|
||||||
6. `WPG-REQ-06`: Absolute placement aliases SHALL resolve shell-known HOME spellings, dot segments,
|
|
||||||
repeated separators, and existing symlink parents before the HOME boundary comparison.
|
|
||||||
7. Relative targets whose effective path depends on the shell cwd are out of scope and tracked by
|
|
||||||
#1197.
|
|
||||||
|
|
||||||
### Acceptance and verification
|
|
||||||
|
|
||||||
1. Git's own option parser accepts each tested flag, including generated `--no-*` forms, while the
|
|
||||||
guard allows a HOME-valued source with an explicit safe destination.
|
|
||||||
2. Equivalent clone and worktree fixtures cover rule-generated negations and remain discriminating
|
|
||||||
against the prior head where the defect existed.
|
|
||||||
3. Real HOME destinations and both `--separate-git-dir` forms remain blocked, including placements
|
|
||||||
after shell command boundaries.
|
|
||||||
4. The full hermetic guard suite, syntax/static checks, adversarial probes, independent review, and
|
|
||||||
terminal-green CI pass before merge.
|
|
||||||
5. Any option-classification residual is documented with its deliberate failure direction.
|
|
||||||
|
|
||||||
### Constraints, risks, and assumptions
|
|
||||||
|
|
||||||
- Security and usability are co-equal: neither a placement bypass nor routine over-block is an
|
|
||||||
acceptable repair.
|
|
||||||
- `ASSUMPTION:` The value-taking option surface exposed by the installed Git version is closed and
|
|
||||||
measurable through Git's own parser/help output; rationale: boolean flags are rule-generated,
|
|
||||||
while separate-value options have explicit grammar and must be classified as such.
|
|
||||||
- Risk: a future Git release may add a new value-taking placement option. Mitigation: document the
|
|
||||||
chosen residual direction and pin every currently supported placement option in behavior tests.
|
|
||||||
- Risk: a symlink can be replaced after pre-execution canonicalization. Mitigation: resolve every
|
|
||||||
existing parent physically and document the remaining inherent TOCTOU window; the worktree helper
|
|
||||||
remains the authoritative path-derivation mechanism, with atomic closure tracked by #1199.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Assumptions
|
## Assumptions
|
||||||
|
|
||||||
1. RESOLVED: **pgvector is sufficient** for semantic search at v0.1.0 scale (personal/family/team = thousands to low hundreds-of-thousands of vectors). `@mosaicstack/memory` defines a `VectorStore` interface with pgvector as the default adapter. The interface boundary makes Qdrant a drop-in migration if PG resource contention or scale demands it later. Zero additional infrastructure for v0.1.0. Rationale: Reduces ops burden; pgvector HNSW indexes are fast at this scale; interface abstraction costs almost nothing now.
|
1. RESOLVED: **pgvector is sufficient** for semantic search at v0.1.0 scale (personal/family/team = thousands to low hundreds-of-thousands of vectors). `@mosaicstack/memory` defines a `VectorStore` interface with pgvector as the default adapter. The interface boundary makes Qdrant a drop-in migration if PG resource contention or scale demands it later. Zero additional infrastructure for v0.1.0. Rationale: Reduces ops burden; pgvector HNSW indexes are fast at this scale; interface abstraction costs almost nothing now.
|
||||||
|
|||||||
+1
-15
@@ -1,19 +1,5 @@
|
|||||||
# Tasks — MVP (Top-Level Rollup)
|
# Tasks — MVP (Top-Level Rollup)
|
||||||
|
|
||||||
> ---
|
|
||||||
>
|
|
||||||
> **STATUS: SUPERSEDED — 2026-08-20.** kind `tracking` · superseded by `docs/fleet/NORTH_STAR.yaml`
|
|
||||||
>
|
|
||||||
> This file is the pre-backlog tracking mechanism. `NS-2` in the north star declares the
|
|
||||||
> replacement: every backlog item is a Mosaic Backlog card projected from the YAML. That
|
|
||||||
> model replaced this one and nobody retired the old file, so it kept reading as
|
|
||||||
> authoritative while going stale.
|
|
||||||
>
|
|
||||||
> **Do not trust a status in this file.** Verified 2026-08-20: it was already behind the
|
|
||||||
> code when it froze five weeks ago.
|
|
||||||
>
|
|
||||||
> Kept as a record of what was believed. Do not update it; update the YAML.
|
|
||||||
|
|
||||||
> Single-writer: orchestrator only. Workers read but never modify.
|
> Single-writer: orchestrator only. Workers read but never modify.
|
||||||
>
|
>
|
||||||
> **Mission:** mvp-20260312
|
> **Mission:** mvp-20260312
|
||||||
@@ -122,7 +108,7 @@ Active workstream is **W1 — Federation v1**. Workers should:
|
|||||||
|
|
||||||
## north-star doctrine consolidation — doc PR — feat/north-star-doctrine
|
## north-star doctrine consolidation — doc PR — feat/north-star-doctrine
|
||||||
|
|
||||||
- Status: applied Mos's consolidated merge-map to docs/fleet/FLEET-DOCTRINE.md (budget governance + control plane/central register + 200k cap + delegation + unified-identity Fleet + role-based naming + tmux security + drift re-captures). Doctrine only; #622/#623/#625/#628 out-of-scope. Conflict checklist green. Detail: scratchpads/north-star-doctrine.md.
|
- Status: applied Mos's consolidated merge-map to docs/fleet/north-star.md (budget governance + control plane/central register + 200k cap + delegation + unified-identity Fleet + role-based naming + tmux security + drift re-captures). Doctrine only; #622/#623/#625/#628 out-of-scope. Conflict checklist green. Detail: scratchpads/north-star-doctrine.md.
|
||||||
|
|
||||||
## #631 — re-seed preserves user fleet data (CRITICAL) — fix/631-reseed-preserves-fleet-data
|
## #631 — re-seed preserves user fleet data (CRITICAL) — fix/631-reseed-preserves-fleet-data
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,7 @@
|
|||||||
3. [Provider Configuration](#provider-configuration)
|
3. [Provider Configuration](#provider-configuration)
|
||||||
4. [MCP Server Configuration](#mcp-server-configuration)
|
4. [MCP Server Configuration](#mcp-server-configuration)
|
||||||
5. [Environment Variables Reference](#environment-variables-reference)
|
5. [Environment Variables Reference](#environment-variables-reference)
|
||||||
6. [Pi Goal Loop Operations](#pi-goal-loop-operations)
|
6. [Local Fleet Canary](./fleet-local-canary.md)
|
||||||
7. [Local Fleet Canary](./fleet-local-canary.md)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -265,16 +264,6 @@ Each OIDC provider requires its client ID, client secret, and issuer URL togethe
|
|||||||
| `AGENT_SYSTEM_PROMPT` | — | Platform-level system prompt injected into all sessions |
|
| `AGENT_SYSTEM_PROMPT` | — | Platform-level system prompt injected into all sessions |
|
||||||
| `AGENT_USER_TOOLS` | all tools | Comma-separated allowlist of tools for non-admin users |
|
| `AGENT_USER_TOOLS` | all tools | Comma-separated allowlist of tools for non-admin users |
|
||||||
|
|
||||||
### Mosaic Pi goal loop
|
|
||||||
|
|
||||||
| Variable | Default | Description |
|
|
||||||
| ----------------------------- | ------- | -------------------------------------------------------------------- |
|
|
||||||
| `MOSAIC_GOAL_MAX_TURNS` | `40` | Per-goal autonomous turn limit; accepted range `1..500` |
|
|
||||||
| `MOSAIC_GOAL_MAX_NO_PROGRESS` | `6` | Consecutive identical progress-report limit; accepted range `1..100` |
|
|
||||||
|
|
||||||
These variables are consumed by the framework-owned Pi goal extension at goal creation. Invalid or
|
|
||||||
out-of-range values fall back to the defaults; they do not disable the bounds.
|
|
||||||
|
|
||||||
### Providers
|
### Providers
|
||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
@@ -385,29 +374,3 @@ Session cleanup is scoped to one session identifier and only removes that sessio
|
|||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
| ----------------------- | ----------------------------- | ------------------------------------------ |
|
| ----------------------- | ----------------------------- | ------------------------------------------ |
|
||||||
| `MOSAIC_WORKSPACE_ROOT` | monorepo root (auto-detected) | Root path for mission workspace operations |
|
| `MOSAIC_WORKSPACE_ROOT` | monorepo root (auto-detected) | Root path for mission workspace operations |
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Pi Goal Loop Operations
|
|
||||||
|
|
||||||
The reviewed runtime asset is deployed at
|
|
||||||
`~/.config/mosaic/runtime/pi/goal-extension.ts` by framework install/update. Do not install another
|
|
||||||
copy under `~/.pi/agent/extensions/`; duplicate registration can create suffixed commands and two
|
|
||||||
competing lifecycle controllers.
|
|
||||||
|
|
||||||
Operational checks:
|
|
||||||
|
|
||||||
1. Run `mosaic pi` and verify `/goal help` is available.
|
|
||||||
2. Use `/goal status` to inspect phase, turn/no-progress limits, compaction checks, and evidence.
|
|
||||||
Reports persist in Pi session data; controller-owned state redacts common credential shapes, but
|
|
||||||
Pi's model/tool-call history is separate. Operators must not place secrets or raw sensitive output
|
|
||||||
in goals, pause reasons, or evidence.
|
|
||||||
3. Use `/goal pause <reason>` before planned maintenance or manual investigation. Pause and cancel
|
|
||||||
abort the current goal-driven run when Pi is busy.
|
|
||||||
4. Use `/goal resume` only after addressing a blocker; counters restart with the configured bounds.
|
|
||||||
5. Use `/goal cancel` before replacing an unfinished goal.
|
|
||||||
|
|
||||||
A blocked or exhausted goal remains stopped and visible; Mosaic does not automatically raise its
|
|
||||||
limits or restart the process. Framework sync owns file deployment, while Pi's native session file
|
|
||||||
owns branch replay. Process/host restart remains the responsibility of the existing runtime or fleet
|
|
||||||
supervisor.
|
|
||||||
|
|||||||
@@ -8,10 +8,9 @@
|
|||||||
4. [Tasks](#tasks)
|
4. [Tasks](#tasks)
|
||||||
5. [Settings](#settings)
|
5. [Settings](#settings)
|
||||||
6. [CLI Usage](#cli-usage)
|
6. [CLI Usage](#cli-usage)
|
||||||
7. [Pi Persistent Goals](#pi-persistent-goals)
|
7. [Sub-package Commands](#sub-package-commands)
|
||||||
8. [Sub-package Commands](#sub-package-commands)
|
8. [Telemetry](#telemetry)
|
||||||
9. [Telemetry](#telemetry)
|
9. [Local Fleet Canary](./fleet-local-canary.md)
|
||||||
10. [Local Fleet Canary](./fleet-local-canary.md)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -318,57 +317,6 @@ mosaic prdy
|
|||||||
mosaic quality-rails
|
mosaic quality-rails
|
||||||
```
|
```
|
||||||
|
|
||||||
## Pi Persistent Goals
|
|
||||||
|
|
||||||
`mosaic pi` loads a Mosaic-owned goal extension from
|
|
||||||
`~/.config/mosaic/runtime/pi/goal-extension.ts`. It is deliberately not installed in
|
|
||||||
`~/.pi/agent/extensions/`; framework installation and updates manage it with the rest of the Mosaic
|
|
||||||
runtime assets.
|
|
||||||
|
|
||||||
Start Pi, then set a goal:
|
|
||||||
|
|
||||||
```text
|
|
||||||
/goal set Deliver the feature, tests, documentation, and verification evidence
|
|
||||||
# Shorthand:
|
|
||||||
/goal Deliver the feature, tests, documentation, and verification evidence
|
|
||||||
```
|
|
||||||
|
|
||||||
Control and inspect the loop with:
|
|
||||||
|
|
||||||
| Command | Behavior |
|
|
||||||
| ---------------------- | ------------------------------------------------------------------ |
|
|
||||||
| `/goal status` | Show phase, limits, compaction checks, latest report, and evidence |
|
|
||||||
| `/goal pause [reason]` | Stop autonomous continuation while preserving the goal |
|
|
||||||
| `/goal resume` | Resume with fresh turn and no-progress counters |
|
|
||||||
| `/goal cancel` | Cancel the goal and remove its active status |
|
|
||||||
| `/goal help` | Show command help |
|
|
||||||
|
|
||||||
While a goal is active, Mosaic injects its contract before every Pi model request and checks every
|
|
||||||
completed model/tool turn. The agent ends each work cycle with the structured
|
|
||||||
`mosaic_goal_report` tool. `achieved` is provisional until a second consecutive report rechecks the
|
|
||||||
whole goal with evidence. A continuation report or a successful compaction resets provisional
|
|
||||||
verification.
|
|
||||||
|
|
||||||
Goal statements and reports are stored in Pi session data. Mosaic redacts common credential shapes
|
|
||||||
before appending its goal-state entries and before goal tool output or `/goal status`, but
|
|
||||||
pattern-based redaction is not a secret store. Pi's own model-message and tool-call records are
|
|
||||||
outside that redactor. Never put tokens, passwords, private keys, connection strings, or raw
|
|
||||||
sensitive output in a goal or report; cite the command, artifact, and pass/fail result instead.
|
|
||||||
|
|
||||||
The loop stops instead of running forever when it is paused, blocked, cancelled, verified, reaches
|
|
||||||
its turn limit, or repeats the same no-progress report too many times. Defaults are 40 turns and 6
|
|
||||||
repeated no-progress reports. Operators may lower or raise them within enforced bounds before
|
|
||||||
launching Pi:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
MOSAIC_GOAL_MAX_TURNS=60 MOSAIC_GOAL_MAX_NO_PROGRESS=8 mosaic pi
|
|
||||||
```
|
|
||||||
|
|
||||||
Goal state is branch-specific Pi session data. It survives compaction and session resume, but Pi's
|
|
||||||
process still must be relaunched or supervised after a process/host failure. This initial verifier
|
|
||||||
checks structured evidence twice; it cannot mathematically prove every arbitrary natural-language
|
|
||||||
goal. Use explicit acceptance criteria and inspect `/goal status` for consequential work.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Claude Code Skill Registration
|
### Claude Code Skill Registration
|
||||||
|
|||||||
@@ -1,21 +1,5 @@
|
|||||||
# Tasks — Federation v1
|
# Tasks — Federation v1
|
||||||
|
|
||||||
> ---
|
|
||||||
>
|
|
||||||
> **STATUS: SUPERSEDED — 2026-08-20.** kind `tracking` · superseded by `docs/fleet/NORTH_STAR.yaml`
|
|
||||||
>
|
|
||||||
> This file is the pre-backlog tracking mechanism. `NS-2` in the north star declares the
|
|
||||||
> replacement: every backlog item is a Mosaic Backlog card projected from the YAML. That
|
|
||||||
> model replaced this one and nobody retired the old file, so it kept reading as
|
|
||||||
> authoritative while going stale.
|
|
||||||
>
|
|
||||||
> **Do not trust a status in this file.** Verified 2026-08-20: it was already behind the
|
|
||||||
> code when it froze five weeks ago. `FED-M3-06` is marked not-started and `get.controller.ts`
|
|
||||||
> has existed for eight weeks; `FED-M3-10/11` claim no tests exist while fifteen spec files
|
|
||||||
> do. `FED-M2-DEPLOY-IMG-FIX` names `apps/gateway/Dockerfile`, which is not in the repo.
|
|
||||||
>
|
|
||||||
> Kept as a record of what was believed. Do not update it; update the YAML.
|
|
||||||
|
|
||||||
> Single-writer: orchestrator only. Workers read but never modify.
|
> Single-writer: orchestrator only. Workers read but never modify.
|
||||||
>
|
>
|
||||||
> **Mission:** federation-v1-20260419
|
> **Mission:** federation-v1-20260419
|
||||||
|
|||||||
+10
-13
@@ -5,14 +5,14 @@ Generated environment files are rebuildable projections, not an operator-editabl
|
|||||||
|
|
||||||
## Launch chain
|
## Launch chain
|
||||||
|
|
||||||
| Layer | Responsibility |
|
| Layer | Responsibility |
|
||||||
| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| Roster | `fleet/roster.yaml` supplies the agent name, class, supported runtime, model, reasoning, tool policy, workdir, and tmux socket; Git identity is derived from the exact agent name. |
|
| Roster | `fleet/roster.yaml` supplies the agent name, class, supported runtime, model, reasoning, tool policy, workdir, and tmux socket. |
|
||||||
| Projection writer | Renders deterministic fleet/agents/<name>.env.generated from the roster. |
|
| Projection writer | Renders deterministic fleet/agents/<name>.env.generated from the roster. |
|
||||||
| Optional local data | Reads a strict, data-only fleet/agents/<name>.env.local; it cannot shadow generated keys. |
|
| Optional local data | Reads a strict, data-only fleet/agents/<name>.env.local; it cannot shadow generated keys. |
|
||||||
| systemd | Starts the launcher with env -i and fixed bootstrap data. It does not preload either environment file. |
|
| systemd | Starts the launcher with env -i and fixed bootstrap data. It does not preload either environment file. |
|
||||||
| session launcher | Validates generated and local data before it queries, creates, or stops an exact tmux session. |
|
| session launcher | Validates generated and local data before it queries, creates, or stops an exact tmux session. |
|
||||||
| runtime launch | Derives the fixed mosaic yolo <runtime> argument array from validated roster data, then seeds the runtime contract. |
|
| runtime launch | Derives the fixed mosaic yolo <runtime> argument array from validated roster data, then seeds the runtime contract. |
|
||||||
|
|
||||||
The launcher never `source`s or `eval`s an environment file and never accepts an environment-supplied
|
The launcher never `source`s or `eval`s an environment file and never accepts an environment-supplied
|
||||||
command. `MOSAIC_AGENT_COMMAND`, command/channel overrides, unknown keys, generated-key shadowing,
|
command. `MOSAIC_AGENT_COMMAND`, command/channel overrides, unknown keys, generated-key shadowing,
|
||||||
@@ -24,7 +24,6 @@ secret-like key names, duplicate keys, comments, quoted/export syntax, and unsaf
|
|||||||
|
|
||||||
```dotenv
|
```dotenv
|
||||||
MOSAIC_AGENT_NAME=<roster name>
|
MOSAIC_AGENT_NAME=<roster name>
|
||||||
MOSAIC_GIT_IDENTITY=<roster name>
|
|
||||||
MOSAIC_AGENT_CLASS=<roster class>
|
MOSAIC_AGENT_CLASS=<roster class>
|
||||||
MOSAIC_AGENT_RUNTIME=<roster runtime>
|
MOSAIC_AGENT_RUNTIME=<roster runtime>
|
||||||
MOSAIC_AGENT_MODEL=<roster model hint>
|
MOSAIC_AGENT_MODEL=<roster model hint>
|
||||||
@@ -34,10 +33,8 @@ MOSAIC_AGENT_WORKDIR=<absolute roster work directory>
|
|||||||
MOSAIC_TMUX_SOCKET=<roster socket or empty>
|
MOSAIC_TMUX_SOCKET=<roster socket or empty>
|
||||||
```
|
```
|
||||||
|
|
||||||
`MOSAIC_GIT_IDENTITY` is not independently configurable: it must equal `MOSAIC_AGENT_NAME`, preventing
|
The generated launch contract supports `claude`, `codex`, `opencode`, and `pi`. mosaic fleet add
|
||||||
split runtime and repository identity authority. The generated launch contract supports `claude`,
|
rejects another runtime before it writes the roster or modifies generated, local, or quarantine state.
|
||||||
`codex`, `opencode`, and `pi`. mosaic fleet add rejects another runtime before it writes the roster or
|
|
||||||
modifies generated, local, or quarantine state.
|
|
||||||
The legacy dogfood stub remains an observability-only canary on its separate `mosaic-factory` socket;
|
The legacy dogfood stub remains an observability-only canary on its separate `mosaic-factory` socket;
|
||||||
it has no generated-launch adapter and cannot be added through this path.
|
it has no generated-launch adapter and cannot be added through this path.
|
||||||
|
|
||||||
|
|||||||
+25
-45
@@ -3,7 +3,7 @@
|
|||||||
> **Generated file — do not edit by hand.**
|
> **Generated file — do not edit by hand.**
|
||||||
> Projected deterministically from [`NORTH_STAR.yaml`](./NORTH_STAR.yaml) by the pure
|
> Projected deterministically from [`NORTH_STAR.yaml`](./NORTH_STAR.yaml) by the pure
|
||||||
> generator in `packages/mosaic/src/commands/fleet.ts` (`renderNorthStarMarkdown`).
|
> generator in `packages/mosaic/src/commands/fleet.ts` (`renderNorthStarMarkdown`).
|
||||||
> Edit the YAML, then regenerate. Self-contained Mosaic.
|
> Edit the YAML, then regenerate. Self-contained Mosaic — no Hermes dependency.
|
||||||
|
|
||||||
## Mission
|
## Mission
|
||||||
|
|
||||||
@@ -11,7 +11,7 @@ A self-driving Mosaic system that 24/7 unattended converts a machine-readable go
|
|||||||
|
|
||||||
## Substrate
|
## Substrate
|
||||||
|
|
||||||
The Mosaic Backlog is the backlog of record + dispatch engine, built on Mosaic's native Postgres storage service (@mosaicstack/db drizzle; PGlite-embedded by default, full Postgres by config).
|
The Mosaic Backlog is the backlog of record + dispatch engine, built on Mosaic's native Postgres storage service (@mosaicstack/db drizzle; PGlite-embedded by default, full Postgres by config). NOT Hermes.
|
||||||
|
|
||||||
## Standing objectives
|
## Standing objectives
|
||||||
|
|
||||||
@@ -24,18 +24,16 @@ The Mosaic Backlog is the backlog of record + dispatch engine, built on Mosaic's
|
|||||||
- **NS-7** — Meta-loop (session-review + enhancer) continuously proposes small fleet-improvement PRs.
|
- **NS-7** — Meta-loop (session-review + enhancer) continuously proposes small fleet-improvement PRs.
|
||||||
- **NS-8** — Single operator-flippable PAUSE kill-switch (fleet/run/PAUSED) honored before every dispatch and every merge.
|
- **NS-8** — Single operator-flippable PAUSE kill-switch (fleet/run/PAUSED) honored before every dispatch and every merge.
|
||||||
- **NS-9** — Mosaic is a general-purpose multi-agent system: the user declares the SYSTEM TYPE to run (e.g. software delivery, personal assistant, research, business/operations) and the orchestrator provisions the matching persona roster and org structure from a cross-domain baseline persona library; the delivery/coding fleet is one profile among many.
|
- **NS-9** — Mosaic is a general-purpose multi-agent system: the user declares the SYSTEM TYPE to run (e.g. software delivery, personal assistant, research, business/operations) and the orchestrator provisions the matching persona roster and org structure from a cross-domain baseline persona library; the delivery/coding fleet is one profile among many.
|
||||||
- **NS-10** — An adoption is not complete until the mechanism it replaces is removed. Two live conventions for one concern is the defect, not a transition state. Measured 2026-08-20: brain-home adopted by 9 modules and not 10; MOSAIC_HOME honored in 4 places, each re-deriving it; backlog cards declared while TASKS.md files stayed authoritative. Every one was decided correctly and left half-applied.
|
|
||||||
|
|
||||||
## Success criteria
|
## Success criteria
|
||||||
|
|
||||||
- **AC-NS-0** (tier 0) — The operator launches an agent on any configured harness with one command, observes its state and sends it work without attaching to a terminal multiplexer.
|
- **AC-NS-1** — The supervisor keeps a two-agent floor (1 orchestrator + >=1 enhancer) healthy across reboot.
|
||||||
- **AC-NS-1** (tier 1) — The supervisor keeps a two-agent floor (1 orchestrator + >=1 enhancer) healthy across reboot.
|
- **AC-NS-2** — A goal added to this YAML is decomposed to cards and either merged or escalated, with no human in the loop.
|
||||||
- **AC-NS-2** (tier 1) — A goal added to this YAML is decomposed to cards and either merged or escalated, with no human in the loop.
|
- **AC-NS-3** — No PR merges with failure/error/no-status/timeout CI, and none bypass pr-merge.sh.
|
||||||
- **AC-NS-3** (tier 1) — No PR merges with failure/error/no-status/timeout CI, and none bypass pr-merge.sh.
|
- **AC-NS-4** — TTL is enforced on claims; token caps remain advisory until a real meter exists.
|
||||||
- **AC-NS-4** (tier 1) — TTL is enforced on claims; token caps remain advisory until a real meter exists.
|
- **AC-NS-5** — Flipping fleet/run/PAUSED halts dispatch and merges within one tick.
|
||||||
- **AC-NS-5** (tier 1) — Flipping fleet/run/PAUSED halts dispatch and merges within one tick.
|
- **AC-NS-6** — A user can declare a system type and the fleet provisions the matching persona roster + topology from the baseline library, with no code change.
|
||||||
- **AC-NS-6** (tier 2) — A user can declare a system type and the fleet provisions the matching persona roster + topology from the baseline library, with no code change.
|
- **AC-NS-7** — A user-customized persona (edited or added via the orchestrator) survives mosaic update: baseline reseed never clobbers user overrides.
|
||||||
- **AC-NS-7** (tier 2) — A user-customized persona (edited or added via the orchestrator) survives mosaic update: baseline reseed never clobbers user overrides.
|
|
||||||
|
|
||||||
## Workstreams
|
## Workstreams
|
||||||
|
|
||||||
@@ -47,44 +45,26 @@ The Mosaic Backlog is the backlog of record + dispatch engine, built on Mosaic's
|
|||||||
| D | Merge-gate — single approver, pr-merge.sh after CI wait |
|
| D | Merge-gate — single approver, pr-merge.sh after CI wait |
|
||||||
| E | Meta-loop — session-review + enhancer improvement PRs |
|
| E | Meta-loop — session-review + enhancer improvement PRs |
|
||||||
| F | Safety-rails — TTL claims, advisory spend, PAUSE kill-switch |
|
| F | Safety-rails — TTL claims, advisory spend, PAUSE kill-switch |
|
||||||
| G | Kill-switch — operator PAUSE honored before dispatch and merge |
|
|
||||||
| H | Personas & system profiles — cross-domain library, system-type provisioning, update-surviving customization |
|
| H | Personas & system profiles — cross-domain library, system-type provisioning, update-surviving customization |
|
||||||
| I | Operator surface — launcher, fleet visibility, reliable steering (tier 0) |
|
|
||||||
| J | Web control plane — browser surface over the gateway (tier 1) |
|
|
||||||
| K | Clients — desktop and mobile over the same backend (tier 2) |
|
|
||||||
| L | Auth profiles — per-provider accounts, per-session selection (tier 2) |
|
|
||||||
|
|
||||||
## Goals (backlog projection)
|
## Goals (backlog projection)
|
||||||
|
|
||||||
| id | title | tier | phase | priority | depends_on |
|
| id | title | phase | priority | depends_on |
|
||||||
| --- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---- | ----- | ----------- | -------------- |
|
| --- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ----------- | ---------- |
|
||||||
| A1 | Machine-readable NORTH_STAR.yaml + Markdown projection | 1 | 1 | must-have | — |
|
| A1 | Machine-readable NORTH_STAR.yaml + Markdown projection | 1 | must-have | — |
|
||||||
| A2 | Mosaic Backlog schema + storage-service card store (drizzle/PGlite) | 1 | 1 | must-have | A1 |
|
| A2 | Mosaic Backlog schema + storage-service card store (drizzle/PGlite) | 1 | must-have | A1 |
|
||||||
| A3a | Card lifecycle — create/claim/release with stable ids + depends_on DAG | 1 | 1 | must-have | A2 |
|
| A3a | Card lifecycle — create/claim/release with stable ids + depends_on DAG | 1 | must-have | A2 |
|
||||||
| A3b | TTL-bounded claim enforcement (wall-clock) on cards | 1 | 1 | must-have | A3a |
|
| A3b | TTL-bounded claim enforcement (wall-clock) on cards | 1 | must-have | A3a |
|
||||||
| A4 | Advisory spend projection per card (degrades to TTL, no real meter) | 1 | 1 | should-have | A3a |
|
| A4 | Advisory spend projection per card (degrades to TTL, no real meter) | 1 | should-have | A3a |
|
||||||
| B1 | Supervisor tick — readiness scan, two-agent-floor health check | 1 | 2 | must-have | A3a |
|
| B1 | Supervisor tick — readiness scan, two-agent-floor health check | 2 | must-have | A3a |
|
||||||
| B2 | Native dispatch/claim — assign ready dependency-satisfied work | 1 | 2 | must-have | A3b, B1 |
|
| B2 | Native dispatch/claim — assign ready dependency-satisfied work | 2 | must-have | A3b, B1 |
|
||||||
| B3a | Planner decompose — goal added to YAML → cards | 1 | 2 | must-have | A2, B1 |
|
| B3a | Planner decompose — goal added to YAML → cards | 2 | must-have | A2, B1 |
|
||||||
| B3b | Replan request on empty backlog; escalate on no-decompose | 1 | 2 | should-have | B3a |
|
| B3b | Replan request on empty backlog; escalate on no-decompose | 2 | should-have | B3a |
|
||||||
| G1 | PAUSE kill-switch + merge-gate honored before dispatch and merge | 1 | 2 | must-have | B2 |
|
| G1 | PAUSE kill-switch + merge-gate honored before dispatch and merge | 2 | must-have | B2 |
|
||||||
| H1 | Cross-domain baseline persona library (exec, marketing, ops, research, assistant + engineering roles) | 2 | 1 | must-have | A1 |
|
| H1 | Cross-domain baseline persona library (exec, marketing, ops, research, assistant + engineering roles) | 1 | must-have | A1 |
|
||||||
| H2 | System-type profiles — declarative mapping of system type to persona roster + topology | 2 | 2 | must-have | H1 |
|
| H2 | System-type profiles — declarative mapping of system type to persona roster + topology | 2 | must-have | H1 |
|
||||||
| H3 | System-type provisioning — user declares type; orchestrator instantiates the matching roster + structure | 2 | 2 | must-have | H2 |
|
| H3 | System-type provisioning — user declares type; orchestrator instantiates the matching roster + structure | 2 | must-have | H2 |
|
||||||
| H4 | Update-surviving persona customization — ad-hoc edits/additions persisted in a PRESERVE-protected override layer (baseline merged with overrides) | 2 | 2 | must-have | H1 |
|
| H4 | Update-surviving persona customization — ad-hoc edits/additions persisted in a PRESERVE-protected override layer (baseline merged with overrides) | 2 | must-have | H1 |
|
||||||
| A5 | NORTH_STAR schema validator — every goal's workstream declared, every workstream has a goal, every depends_on id exists, every tier has a success criterion; runs in CI beside the Markdown regeneration check | 0 | 1 | must-have | A1 |
|
|
||||||
| I1 | One home resolver — a single function resolving MOSAIC_HOME with a sane default, adopted by every module. Today brain-home.ts is imported by 9 modules while 10 still use DEFAULT_MOSAIC_HOME, and MOSAIC_HOME is re-derived ad hoc in 4 places. NS-10 applies - finish the adoption and delete the second path | 0 | 1 | must-have | — |
|
|
||||||
| I2 | mosaic fleet ps sees the fleet that is actually running. Three measured blockers: the roster declares socket `mosaic-fleet` which does not exist, the 18 live sessions are on the default socket, and nothing writes a roster because seats are launched outside the CLI. Make the socket configurable and the roster written at launch, or make ps read tmux + seat dirs directly | 0 | 1 | must-have | I1 |
|
|
||||||
| I3 | Migrate fleet steering onto mosaic agent send --verify (exists, FLEET-OBS-005, spec FR-5) and retire tools/tmux/agent-send.sh, which forges the sender (D33) and returns an uninformative rc (D16, D34). FR-5 predates those defects by a month | 0 | 1 | must-have | I1 |
|
|
||||||
| I4 | mosaic fleet absorbs what launch-seat.sh does and launch-seat.sh is deprecated: compose the prompt file set, force the skill set, wire the style hook, fail closed on any unreadable input, export per-seat git identity, and register the seat in the roster. launch-seat.sh was a manual method; it is the reference implementation, not the destination | 0 | 1 | must-have | I1, I5 |
|
|
||||||
| I5 | Harness probe matrix — verify a working prompt-injection path for claude, codex, opencode and pi, and refuse any runtime whose path is unverified. AC-NS-0 clause 1 ("any configured harness") rests on this. The probe work in docs/plans/2026-08-19_launch-seat-multi-runtime.md (brain, untracked) transfers; its launch-seat.sh target does not | 0 | 1 | must-have | — |
|
|
||||||
| I6 | Finish the heartbeat responder (FLEET-OBS-002, the only Phase-2 task still in-progress; spec FR-2). Health must mean "answered a heartbeat", not "pane alive" — pane state measured two seats wrong on 2026-08-20 | 0 | 1 | must-have | — |
|
|
||||||
| I7 | Independent review and live-fleet dogfood of the Phase-2 verbs (FLEET-OBS-008), then land them (FLEET-OBS-009). Implementation is done and verification is not; "done" in a task file frozen five weeks is not evidence | 0 | 1 | must-have | I2, I3, I4, I6 |
|
|
||||||
| I8 | Neutralize misleading documentation — supersede headers on docs that state a stale status, a false blocker or a retired mechanism. Cheap, and it is tier 0 because a stale doc does not merely fail to help an agent, it actively misroutes one. Rebuilding the documentation is a separate and later job | 0 | 1 | must-have | — |
|
|
||||||
| I9 | Study t3code's agent-attach and multi-provider auth methods and record what transfers. Reference only — Mosaic implements its own within the stack, never adopts the code and never takes the dependency. Informs HOW I/J/K/L are built, not whether | 0 | 1 | should-have | — |
|
|
||||||
| J1 | Web control plane over the gateway — fleet visibility and steering in a browser, same data source as I2 | 1 | 3 | must-have | I2 |
|
|
||||||
| K1 | Desktop and mobile clients against the gateway, authenticated | 2 | 4 | must-have | J1 |
|
|
||||||
| L1 | Per-provider auth profiles with per-session selection | 2 | 4 | must-have | I4 |
|
|
||||||
|
|
||||||
## Assumptions (vetoable)
|
## Assumptions (vetoable)
|
||||||
|
|
||||||
|
|||||||
+2
-129
@@ -6,7 +6,7 @@
|
|||||||
# packages/mosaic/src/commands/fleet.ts (renderNorthStarMarkdown). Edit the YAML,
|
# packages/mosaic/src/commands/fleet.ts (renderNorthStarMarkdown). Edit the YAML,
|
||||||
# never the .md.
|
# never the .md.
|
||||||
#
|
#
|
||||||
# Self-contained Mosaic. The backlog of record is
|
# Self-contained Mosaic. NO Hermes runtime dependency. The backlog of record is
|
||||||
# the Mosaic Backlog on Mosaic's OWN native Postgres storage service.
|
# the Mosaic Backlog on Mosaic's OWN native Postgres storage service.
|
||||||
|
|
||||||
version: 1
|
version: 1
|
||||||
@@ -24,7 +24,7 @@ substrate:
|
|||||||
note: >-
|
note: >-
|
||||||
The Mosaic Backlog is the backlog of record + dispatch engine, built on
|
The Mosaic Backlog is the backlog of record + dispatch engine, built on
|
||||||
Mosaic's native Postgres storage service (@mosaicstack/db drizzle;
|
Mosaic's native Postgres storage service (@mosaicstack/db drizzle;
|
||||||
PGlite-embedded by default, full Postgres by config).
|
PGlite-embedded by default, full Postgres by config). NOT Hermes.
|
||||||
|
|
||||||
standing_objectives:
|
standing_objectives:
|
||||||
- id: NS-1
|
- id: NS-1
|
||||||
@@ -69,53 +69,32 @@ standing_objectives:
|
|||||||
business/operations) and the orchestrator provisions the matching persona
|
business/operations) and the orchestrator provisions the matching persona
|
||||||
roster and org structure from a cross-domain baseline persona library; the
|
roster and org structure from a cross-domain baseline persona library; the
|
||||||
delivery/coding fleet is one profile among many.
|
delivery/coding fleet is one profile among many.
|
||||||
- id: NS-10
|
|
||||||
text: >-
|
|
||||||
An adoption is not complete until the mechanism it replaces is removed.
|
|
||||||
Two live conventions for one concern is the defect, not a transition
|
|
||||||
state. Measured 2026-08-20: brain-home adopted by 9 modules and not 10;
|
|
||||||
MOSAIC_HOME honored in 4 places, each re-deriving it; backlog cards
|
|
||||||
declared while TASKS.md files stayed authoritative. Every one was decided
|
|
||||||
correctly and left half-applied.
|
|
||||||
|
|
||||||
success_criteria:
|
success_criteria:
|
||||||
- id: AC-NS-0
|
|
||||||
tier: 0
|
|
||||||
text: >-
|
|
||||||
The operator launches an agent on any configured harness with one
|
|
||||||
command, observes its state and sends it work without attaching to a
|
|
||||||
terminal multiplexer.
|
|
||||||
- id: AC-NS-1
|
- id: AC-NS-1
|
||||||
tier: 1
|
|
||||||
text: >-
|
text: >-
|
||||||
The supervisor keeps a two-agent floor (1 orchestrator + >=1 enhancer)
|
The supervisor keeps a two-agent floor (1 orchestrator + >=1 enhancer)
|
||||||
healthy across reboot.
|
healthy across reboot.
|
||||||
- id: AC-NS-2
|
- id: AC-NS-2
|
||||||
tier: 1
|
|
||||||
text: >-
|
text: >-
|
||||||
A goal added to this YAML is decomposed to cards and either merged or
|
A goal added to this YAML is decomposed to cards and either merged or
|
||||||
escalated, with no human in the loop.
|
escalated, with no human in the loop.
|
||||||
- id: AC-NS-3
|
- id: AC-NS-3
|
||||||
tier: 1
|
|
||||||
text: >-
|
text: >-
|
||||||
No PR merges with failure/error/no-status/timeout CI, and none bypass
|
No PR merges with failure/error/no-status/timeout CI, and none bypass
|
||||||
pr-merge.sh.
|
pr-merge.sh.
|
||||||
- id: AC-NS-4
|
- id: AC-NS-4
|
||||||
tier: 1
|
|
||||||
text: >-
|
text: >-
|
||||||
TTL is enforced on claims; token caps remain advisory until a real meter
|
TTL is enforced on claims; token caps remain advisory until a real meter
|
||||||
exists.
|
exists.
|
||||||
- id: AC-NS-5
|
- id: AC-NS-5
|
||||||
tier: 1
|
|
||||||
text: >-
|
text: >-
|
||||||
Flipping fleet/run/PAUSED halts dispatch and merges within one tick.
|
Flipping fleet/run/PAUSED halts dispatch and merges within one tick.
|
||||||
- id: AC-NS-6
|
- id: AC-NS-6
|
||||||
tier: 2
|
|
||||||
text: >-
|
text: >-
|
||||||
A user can declare a system type and the fleet provisions the matching
|
A user can declare a system type and the fleet provisions the matching
|
||||||
persona roster + topology from the baseline library, with no code change.
|
persona roster + topology from the baseline library, with no code change.
|
||||||
- id: AC-NS-7
|
- id: AC-NS-7
|
||||||
tier: 2
|
|
||||||
text: >-
|
text: >-
|
||||||
A user-customized persona (edited or added via the orchestrator) survives
|
A user-customized persona (edited or added via the orchestrator) survives
|
||||||
mosaic update: baseline reseed never clobbers user overrides.
|
mosaic update: baseline reseed never clobbers user overrides.
|
||||||
@@ -133,186 +112,80 @@ workstreams:
|
|||||||
title: Meta-loop — session-review + enhancer improvement PRs
|
title: Meta-loop — session-review + enhancer improvement PRs
|
||||||
- id: F
|
- id: F
|
||||||
title: Safety-rails — TTL claims, advisory spend, PAUSE kill-switch
|
title: Safety-rails — TTL claims, advisory spend, PAUSE kill-switch
|
||||||
- id: G
|
|
||||||
title: Kill-switch — operator PAUSE honored before dispatch and merge
|
|
||||||
- id: H
|
- id: H
|
||||||
title: Personas & system profiles — cross-domain library, system-type provisioning, update-surviving customization
|
title: Personas & system profiles — cross-domain library, system-type provisioning, update-surviving customization
|
||||||
- id: I
|
|
||||||
title: Operator surface — launcher, fleet visibility, reliable steering (tier 0)
|
|
||||||
- id: J
|
|
||||||
title: Web control plane — browser surface over the gateway (tier 1)
|
|
||||||
- id: K
|
|
||||||
title: Clients — desktop and mobile over the same backend (tier 2)
|
|
||||||
- id: L
|
|
||||||
title: Auth profiles — per-provider accounts, per-session selection (tier 2)
|
|
||||||
|
|
||||||
# NOTE: workstreams C, D, E and F are declared but currently project no goals.
|
|
||||||
# That is planning debt, not an editing error: their goals have not been written
|
|
||||||
# yet. The A5 validator below reports it rather than letting it stay invisible.
|
|
||||||
|
|
||||||
goals:
|
goals:
|
||||||
- id: A1
|
- id: A1
|
||||||
title: Machine-readable NORTH_STAR.yaml + Markdown projection
|
title: Machine-readable NORTH_STAR.yaml + Markdown projection
|
||||||
phase: 1
|
phase: 1
|
||||||
tier: 1
|
|
||||||
priority: must-have
|
priority: must-have
|
||||||
depends_on: []
|
depends_on: []
|
||||||
- id: A2
|
- id: A2
|
||||||
title: Mosaic Backlog schema + storage-service card store (drizzle/PGlite)
|
title: Mosaic Backlog schema + storage-service card store (drizzle/PGlite)
|
||||||
phase: 1
|
phase: 1
|
||||||
tier: 1
|
|
||||||
priority: must-have
|
priority: must-have
|
||||||
depends_on: [A1]
|
depends_on: [A1]
|
||||||
- id: A3a
|
- id: A3a
|
||||||
title: Card lifecycle — create/claim/release with stable ids + depends_on DAG
|
title: Card lifecycle — create/claim/release with stable ids + depends_on DAG
|
||||||
phase: 1
|
phase: 1
|
||||||
tier: 1
|
|
||||||
priority: must-have
|
priority: must-have
|
||||||
depends_on: [A2]
|
depends_on: [A2]
|
||||||
- id: A3b
|
- id: A3b
|
||||||
title: TTL-bounded claim enforcement (wall-clock) on cards
|
title: TTL-bounded claim enforcement (wall-clock) on cards
|
||||||
phase: 1
|
phase: 1
|
||||||
tier: 1
|
|
||||||
priority: must-have
|
priority: must-have
|
||||||
depends_on: [A3a]
|
depends_on: [A3a]
|
||||||
- id: A4
|
- id: A4
|
||||||
title: Advisory spend projection per card (degrades to TTL, no real meter)
|
title: Advisory spend projection per card (degrades to TTL, no real meter)
|
||||||
phase: 1
|
phase: 1
|
||||||
tier: 1
|
|
||||||
priority: should-have
|
priority: should-have
|
||||||
depends_on: [A3a]
|
depends_on: [A3a]
|
||||||
- id: B1
|
- id: B1
|
||||||
title: Supervisor tick — readiness scan, two-agent-floor health check
|
title: Supervisor tick — readiness scan, two-agent-floor health check
|
||||||
phase: 2
|
phase: 2
|
||||||
tier: 1
|
|
||||||
priority: must-have
|
priority: must-have
|
||||||
depends_on: [A3a]
|
depends_on: [A3a]
|
||||||
- id: B2
|
- id: B2
|
||||||
title: Native dispatch/claim — assign ready dependency-satisfied work
|
title: Native dispatch/claim — assign ready dependency-satisfied work
|
||||||
phase: 2
|
phase: 2
|
||||||
tier: 1
|
|
||||||
priority: must-have
|
priority: must-have
|
||||||
depends_on: [A3b, B1]
|
depends_on: [A3b, B1]
|
||||||
- id: B3a
|
- id: B3a
|
||||||
title: Planner decompose — goal added to YAML → cards
|
title: Planner decompose — goal added to YAML → cards
|
||||||
phase: 2
|
phase: 2
|
||||||
tier: 1
|
|
||||||
priority: must-have
|
priority: must-have
|
||||||
depends_on: [A2, B1]
|
depends_on: [A2, B1]
|
||||||
- id: B3b
|
- id: B3b
|
||||||
title: Replan request on empty backlog; escalate on no-decompose
|
title: Replan request on empty backlog; escalate on no-decompose
|
||||||
phase: 2
|
phase: 2
|
||||||
tier: 1
|
|
||||||
priority: should-have
|
priority: should-have
|
||||||
depends_on: [B3a]
|
depends_on: [B3a]
|
||||||
- id: G1
|
- id: G1
|
||||||
title: PAUSE kill-switch + merge-gate honored before dispatch and merge
|
title: PAUSE kill-switch + merge-gate honored before dispatch and merge
|
||||||
phase: 2
|
phase: 2
|
||||||
tier: 1
|
|
||||||
priority: must-have
|
priority: must-have
|
||||||
depends_on: [B2]
|
depends_on: [B2]
|
||||||
- id: H1
|
- id: H1
|
||||||
title: Cross-domain baseline persona library (exec, marketing, ops, research, assistant + engineering roles)
|
title: Cross-domain baseline persona library (exec, marketing, ops, research, assistant + engineering roles)
|
||||||
phase: 1
|
phase: 1
|
||||||
tier: 2
|
|
||||||
priority: must-have
|
priority: must-have
|
||||||
depends_on: [A1]
|
depends_on: [A1]
|
||||||
- id: H2
|
- id: H2
|
||||||
title: System-type profiles — declarative mapping of system type to persona roster + topology
|
title: System-type profiles — declarative mapping of system type to persona roster + topology
|
||||||
phase: 2
|
phase: 2
|
||||||
tier: 2
|
|
||||||
priority: must-have
|
priority: must-have
|
||||||
depends_on: [H1]
|
depends_on: [H1]
|
||||||
- id: H3
|
- id: H3
|
||||||
title: System-type provisioning — user declares type; orchestrator instantiates the matching roster + structure
|
title: System-type provisioning — user declares type; orchestrator instantiates the matching roster + structure
|
||||||
phase: 2
|
phase: 2
|
||||||
tier: 2
|
|
||||||
priority: must-have
|
priority: must-have
|
||||||
depends_on: [H2]
|
depends_on: [H2]
|
||||||
- id: H4
|
- id: H4
|
||||||
title: Update-surviving persona customization — ad-hoc edits/additions persisted in a PRESERVE-protected override layer (baseline merged with overrides)
|
title: Update-surviving persona customization — ad-hoc edits/additions persisted in a PRESERVE-protected override layer (baseline merged with overrides)
|
||||||
phase: 2
|
phase: 2
|
||||||
tier: 2
|
|
||||||
priority: must-have
|
priority: must-have
|
||||||
depends_on: [H1]
|
depends_on: [H1]
|
||||||
- id: A5
|
|
||||||
title: NORTH_STAR schema validator — every goal's workstream declared, every workstream has a goal, every depends_on id exists, every tier has a success criterion; runs in CI beside the Markdown regeneration check
|
|
||||||
phase: 1
|
|
||||||
tier: 0
|
|
||||||
priority: must-have
|
|
||||||
depends_on: [A1]
|
|
||||||
- id: I1
|
|
||||||
title: One home resolver — a single function resolving MOSAIC_HOME with a sane default, adopted by every module. Today brain-home.ts is imported by 9 modules while 10 still use DEFAULT_MOSAIC_HOME, and MOSAIC_HOME is re-derived ad hoc in 4 places. NS-10 applies - finish the adoption and delete the second path
|
|
||||||
phase: 1
|
|
||||||
tier: 0
|
|
||||||
priority: must-have
|
|
||||||
depends_on: []
|
|
||||||
- id: I2
|
|
||||||
title: 'mosaic fleet ps sees the fleet that is actually running. Three measured blockers: the roster declares socket `mosaic-fleet` which does not exist, the 18 live sessions are on the default socket, and nothing writes a roster because seats are launched outside the CLI. Make the socket configurable and the roster written at launch, or make ps read tmux + seat dirs directly'
|
|
||||||
phase: 1
|
|
||||||
tier: 0
|
|
||||||
priority: must-have
|
|
||||||
depends_on: [I1]
|
|
||||||
- id: I3
|
|
||||||
title: Migrate fleet steering onto mosaic agent send --verify (exists, FLEET-OBS-005, spec FR-5) and retire tools/tmux/agent-send.sh, which forges the sender (D33) and returns an uninformative rc (D16, D34). FR-5 predates those defects by a month
|
|
||||||
phase: 1
|
|
||||||
tier: 0
|
|
||||||
priority: must-have
|
|
||||||
depends_on: [I1]
|
|
||||||
- id: I4
|
|
||||||
title: 'mosaic fleet absorbs what launch-seat.sh does and launch-seat.sh is deprecated: compose the prompt file set, force the skill set, wire the style hook, fail closed on any unreadable input, export per-seat git identity, and register the seat in the roster. launch-seat.sh was a manual method; it is the reference implementation, not the destination'
|
|
||||||
phase: 1
|
|
||||||
tier: 0
|
|
||||||
priority: must-have
|
|
||||||
depends_on: [I1, I5]
|
|
||||||
- id: I5
|
|
||||||
title: Harness probe matrix — verify a working prompt-injection path for claude, codex, opencode and pi, and refuse any runtime whose path is unverified. AC-NS-0 clause 1 ("any configured harness") rests on this. The probe work in docs/plans/2026-08-19_launch-seat-multi-runtime.md (brain, untracked) transfers; its launch-seat.sh target does not
|
|
||||||
phase: 1
|
|
||||||
tier: 0
|
|
||||||
priority: must-have
|
|
||||||
depends_on: []
|
|
||||||
- id: I6
|
|
||||||
title: Finish the heartbeat responder (FLEET-OBS-002, the only Phase-2 task still in-progress; spec FR-2). Health must mean "answered a heartbeat", not "pane alive" — pane state measured two seats wrong on 2026-08-20
|
|
||||||
phase: 1
|
|
||||||
tier: 0
|
|
||||||
priority: must-have
|
|
||||||
depends_on: []
|
|
||||||
- id: I7
|
|
||||||
title: Independent review and live-fleet dogfood of the Phase-2 verbs (FLEET-OBS-008), then land them (FLEET-OBS-009). Implementation is done and verification is not; "done" in a task file frozen five weeks is not evidence
|
|
||||||
phase: 1
|
|
||||||
tier: 0
|
|
||||||
priority: must-have
|
|
||||||
depends_on: [I2, I3, I4, I6]
|
|
||||||
- id: I8
|
|
||||||
title: Neutralize misleading documentation — supersede headers on docs that state a stale status, a false blocker or a retired mechanism. Cheap, and it is tier 0 because a stale doc does not merely fail to help an agent, it actively misroutes one. Rebuilding the documentation is a separate and later job
|
|
||||||
phase: 1
|
|
||||||
tier: 0
|
|
||||||
priority: must-have
|
|
||||||
depends_on: []
|
|
||||||
- id: I9
|
|
||||||
title: Study t3code's agent-attach and multi-provider auth methods and record what transfers. Reference only — Mosaic implements its own within the stack, never adopts the code and never takes the dependency. Informs HOW I/J/K/L are built, not whether
|
|
||||||
phase: 1
|
|
||||||
tier: 0
|
|
||||||
priority: should-have
|
|
||||||
depends_on: []
|
|
||||||
- id: J1
|
|
||||||
title: Web control plane over the gateway — fleet visibility and steering in a browser, same data source as I2
|
|
||||||
phase: 3
|
|
||||||
tier: 1
|
|
||||||
priority: must-have
|
|
||||||
depends_on: [I2]
|
|
||||||
- id: K1
|
|
||||||
title: Desktop and mobile clients against the gateway, authenticated
|
|
||||||
phase: 4
|
|
||||||
tier: 2
|
|
||||||
priority: must-have
|
|
||||||
depends_on: [J1]
|
|
||||||
- id: L1
|
|
||||||
title: Per-provider auth profiles with per-session selection
|
|
||||||
phase: 4
|
|
||||||
tier: 2
|
|
||||||
priority: must-have
|
|
||||||
depends_on: [I4]
|
|
||||||
|
|
||||||
assumptions:
|
assumptions:
|
||||||
- id: ASM-1
|
- id: ASM-1
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# PRD — Mosaic Fleet Suite (init, configure, operate)
|
# PRD — Mosaic Fleet Suite (init, configure, operate)
|
||||||
|
|
||||||
> **Workstream:** W-FLEET (Fleet) under mission `mvp-20260312` · **Phase:** 3→4 productization
|
> **Workstream:** W-FLEET (Fleet) under mission `mvp-20260312` · **Phase:** 3→4 productization
|
||||||
> **North star:** [docs/fleet/FLEET-DOCTRINE.md](./FLEET-DOCTRINE.md) · prior: Phase-2 observability (#579), durable launch (#581), real-agent enablement (#583/#584/#586), releases 0.0.35–0.0.37
|
> **North star:** [docs/fleet/north-star.md](./north-star.md) · prior: Phase-2 observability (#579), durable launch (#581), real-agent enablement (#583/#584/#586), releases 0.0.35–0.0.37
|
||||||
> **Lead:** Jarvis @ `w-jarvis`. **Collaborator:** coder agent @ `dragon-lin` (jwoltje@10.1.10.37:coder0-0).
|
> **Lead:** Jarvis @ `w-jarvis`. **Collaborator:** coder agent @ `dragon-lin` (jwoltje@10.1.10.37:coder0-0).
|
||||||
> Owner of this file: Fleet workstream lead. Does not modify MVP single-writer control-plane files.
|
> Owner of this file: Fleet workstream lead. Does not modify MVP single-writer control-plane files.
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
# PRD — Fleet Phase 2: Operator Observability
|
# PRD — Fleet Phase 2: Operator Observability
|
||||||
|
|
||||||
> **Workstream:** W-FLEET under `mvp-20260312` · **Phase:** 2
|
> **Workstream:** W-FLEET under `mvp-20260312` · **Phase:** 2
|
||||||
> **North star:** [docs/fleet/FLEET-DOCTRINE.md](./FLEET-DOCTRINE.md)
|
> **North star:** [docs/fleet/north-star.md](./north-star.md)
|
||||||
> **Source umbrella PRD:** [docs/PRD.md](../PRD.md) (Mosaic Stack v0.1.0)
|
> **Source umbrella PRD:** [docs/PRD.md](../PRD.md) (Mosaic Stack v0.1.0)
|
||||||
> **Tracks task:** `fleet-observability-1` — restore operator observability into fleet agent sessions.
|
> **Tracks task:** `fleet-observability-1` — restore operator observability into fleet agent sessions.
|
||||||
|
|
||||||
|
|||||||
+1
-16
@@ -1,25 +1,10 @@
|
|||||||
# Tasks — W-FLEET (Fleet) Phase 2: Observability
|
# Tasks — W-FLEET (Fleet) Phase 2: Observability
|
||||||
|
|
||||||
> ---
|
|
||||||
>
|
|
||||||
> **STATUS: SUPERSEDED — 2026-08-20.** kind `tracking` · superseded by `docs/fleet/NORTH_STAR.yaml`
|
|
||||||
>
|
|
||||||
> This file is the pre-backlog tracking mechanism. `NS-2` in the north star declares the
|
|
||||||
> replacement: every backlog item is a Mosaic Backlog card projected from the YAML. That
|
|
||||||
> model replaced this one and nobody retired the old file, so it kept reading as
|
|
||||||
> authoritative while going stale.
|
|
||||||
>
|
|
||||||
> **Do not trust a status in this file.** Verified 2026-08-20: it was already behind the
|
|
||||||
> code when it froze five weeks ago. The `FLEET-OBS` series was the one thing worth salvaging and
|
|
||||||
> is now carried as goals `I2`, `I3`, `I6` and `I7` at tier 0.
|
|
||||||
>
|
|
||||||
> Kept as a record of what was believed. Do not update it; update the YAML.
|
|
||||||
|
|
||||||
> Workstream task file for the Fleet. Single-writer: Fleet workstream lead (orchestrator).
|
> Workstream task file for the Fleet. Single-writer: Fleet workstream lead (orchestrator).
|
||||||
> Workers read but never modify. This is **not** the MVP rollup (`docs/TASKS.md`) — a
|
> Workers read but never modify. This is **not** the MVP rollup (`docs/TASKS.md`) — a
|
||||||
> rollup row is proposed to the MVP orchestrator, not written here.
|
> rollup row is proposed to the MVP orchestrator, not written here.
|
||||||
>
|
>
|
||||||
> Mission: `mvp-20260312` · PRD: [docs/fleet/PRD.md](./PRD.md) · North star: [docs/fleet/FLEET-DOCTRINE.md](./FLEET-DOCTRINE.md)
|
> Mission: `mvp-20260312` · PRD: [docs/fleet/PRD.md](./PRD.md) · North star: [docs/fleet/north-star.md](./north-star.md)
|
||||||
> Status: `not-started` | `in-progress` | `done` | `blocked` | `failed`
|
> Status: `not-started` | `in-progress` | `done` | `blocked` | `failed`
|
||||||
|
|
||||||
| id | status | description | depends_on | agent | pr | notes |
|
| id | status | description | depends_on | agent | pr | notes |
|
||||||
|
|||||||
@@ -3,12 +3,11 @@
|
|||||||
The launcher consumes validated data, not shell configuration.
|
The launcher consumes validated data, not shell configuration.
|
||||||
|
|
||||||
1. Read and validate the canonical roster.
|
1. Read and validate the canonical roster.
|
||||||
2. Render deterministic <name>.env.generated data from that roster, including `MOSAIC_GIT_IDENTITY` derived exactly from the roster agent name.
|
2. Render deterministic <name>.env.generated data from that roster.
|
||||||
3. Parse optional <name>.env.local through a strict allowlist.
|
3. Parse optional <name>.env.local through a strict allowlist.
|
||||||
4. Reject generated-key shadowing, unknown or sensitive-looking keys, unsafe paths/values, duplicates, malformed lines, shell syntax, and command overrides.
|
4. Reject generated-key shadowing, unknown or sensitive-looking keys, unsafe paths/values, duplicates, malformed lines, shell syntax, and command overrides.
|
||||||
5. Reject a Git identity that is unsafe or differs from the generated agent name.
|
5. Derive the runtime command from validated runtime/model/reasoning data.
|
||||||
6. Derive the runtime command from validated runtime/model/reasoning data and pass every generated projection entry through the clean process environment boundary.
|
6. Target only the exact configured tmux socket and roster session after ownership checks.
|
||||||
7. Target only the exact configured tmux socket and roster session after ownership checks.
|
|
||||||
|
|
||||||
## File precedence and ownership
|
## File precedence and ownership
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# F4 — Orchestrator chat connector + Matrix (local homeserver)
|
# F4 — Orchestrator chat connector + Matrix (local homeserver)
|
||||||
|
|
||||||
> **Issue:** #616 · **Doctrine:** `docs/fleet/FLEET-DOCTRINE.md` (#613) — orchestrator-chat-connector decision.
|
> **Issue:** #616 · **Doctrine:** `docs/fleet/north-star.md` (#613) — orchestrator-chat-connector decision.
|
||||||
> **Status:** Phase 1 (abstraction + scaffold) in this PR; Phase 2+ are follow-ups (below).
|
> **Status:** Phase 1 (abstraction + scaffold) in this PR; Phase 2+ are follow-ups (below).
|
||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
|
|||||||
@@ -1,26 +1,9 @@
|
|||||||
---
|
# Mosaic Fleet — North Star
|
||||||
kind: spec
|
|
||||||
parent: docs/fleet/NORTH_STAR.yaml
|
|
||||||
status: active
|
|
||||||
---
|
|
||||||
|
|
||||||
# Mosaic Fleet — Doctrine
|
|
||||||
|
|
||||||
> **This is the WHY. `NORTH_STAR.yaml` is the WHAT and WHEN.**
|
|
||||||
> Renamed from `north-star.md` on 2026-08-20. It sat one character away from the
|
|
||||||
> generated `NORTH_STAR.md` in the same directory, and the two are read by different
|
|
||||||
> populations — the PRDs and TASKS files cite this one, while the agent role contracts
|
|
||||||
> and the generator spec cite the YAML pair. Same-name-different-thing was the confusion;
|
|
||||||
> the content was never in conflict.
|
|
||||||
>
|
|
||||||
> **Nothing here overrides `NORTH_STAR.yaml`.** Where this document states a plan item,
|
|
||||||
> the YAML is authoritative. Where it states a decision, a rationale, or a role
|
|
||||||
> definition, this document is the record and the YAML carries none of it.
|
|
||||||
>
|
|
||||||
> **Workstream:** W-FLEET (Fleet) under mission `mvp-20260312`
|
> **Workstream:** W-FLEET (Fleet) under mission `mvp-20260312`
|
||||||
> **Umbrella:** [docs/MISSION-MANIFEST.md](../MISSION-MANIFEST.md)
|
> **Umbrella:** [docs/MISSION-MANIFEST.md](../MISSION-MANIFEST.md) · [docs/PRD.md](../PRD.md) (Mosaic Stack v0.1.0)
|
||||||
> **Authored:** 2026-06-20. Owner: Fleet workstream lead.
|
> **Status:** doctrine — authored 2026-06-20. Owner of this file: Fleet workstream lead.
|
||||||
> This document does **not** modify the MVP rollup.
|
> This document does **not** modify the MVP rollup; a rollup row is proposed, not written here.
|
||||||
|
|
||||||
## Vision
|
## Vision
|
||||||
|
|
||||||
@@ -281,17 +264,15 @@ Dedicated Postgres **instance** vs. dedicated **schema** in the existing instanc
|
|||||||
Recommendation: dedicated schema, existing instance (a migration file, not new infra);
|
Recommendation: dedicated schema, existing instance (a migration file, not new infra);
|
||||||
re-evaluate if isolation or write-volume demands it.
|
re-evaluate if isolation or write-volume demands it.
|
||||||
|
|
||||||
## Phased roadmap — SUPERSEDED
|
## Phased roadmap
|
||||||
|
|
||||||
Superseded 2026-08-20 by [`NORTH_STAR.yaml`](./NORTH_STAR.yaml), whose `goals` carry both
|
| Phase | Outcome | Status |
|
||||||
a `phase` (build order) and a `tier` (which promise the goal delivers). The five-phase
|
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------- |
|
||||||
table that stood here could not express those as separate axes, and its "Phase 2 —
|
| 0–1 | tmux PoC, hardening, published CLI v0.0.34 (#565–#568) | ✅ done |
|
||||||
Observability ▶ now" row stayed unfalsified for two months because a phase has no exit
|
| **2 — Observability** | fleet ps (host+tenant aware join), heartbeat protocol + dogfood stub answers it, agent watch (read-only), agent send --verify receipts | ▶ now |
|
||||||
test. Tiers do: see `AC-NS-0` through `AC-NS-7`.
|
| 3 — Real runtimes | claude/codex/pi/opencode answer heartbeat; **hybrid lifecycle** (core always-on: **orchestrator + enhancer**; ephemeral workers per lane) | planned |
|
||||||
|
| 4 — Unified definition | one agent schema in gateway; mosaic agent --new → materialized per-tenant session; uid-tenant provisioning; **`fleet` schema migration + `forge-exec` TaskExecutor adapter (forge → `agent-send.sh`)** | planned |
|
||||||
The phase-2 content itself is not lost — it is specified in
|
| 5 — Control plane | federation-backed cross-host × cross-tenant fleet view; **webUI** (surface chosen then) for MVP-X1 parity; **central register live (spend ledger, docs-as-projections, multi-host Kanban)** | planned |
|
||||||
[`PRD.md`](./PRD.md) (Fleet Phase 2: Operator Observability) and is now tracked as
|
|
||||||
goals `I1`–`I5` at tier 0.
|
|
||||||
|
|
||||||
## Decisions of record (2026-06-20, with Jason)
|
## Decisions of record (2026-06-20, with Jason)
|
||||||
|
|
||||||
@@ -35,7 +35,6 @@ values, credential material, or command text.
|
|||||||
|
|
||||||
```dotenv
|
```dotenv
|
||||||
MOSAIC_AGENT_NAME=<roster name>
|
MOSAIC_AGENT_NAME=<roster name>
|
||||||
MOSAIC_GIT_IDENTITY=<roster name>
|
|
||||||
MOSAIC_AGENT_CLASS=<roster class>
|
MOSAIC_AGENT_CLASS=<roster class>
|
||||||
MOSAIC_AGENT_RUNTIME=<roster runtime>
|
MOSAIC_AGENT_RUNTIME=<roster runtime>
|
||||||
MOSAIC_AGENT_MODEL=<roster model hint>
|
MOSAIC_AGENT_MODEL=<roster model hint>
|
||||||
@@ -45,9 +44,8 @@ MOSAIC_AGENT_WORKDIR=<absolute roster work directory>
|
|||||||
MOSAIC_TMUX_SOCKET=<roster socket or empty>
|
MOSAIC_TMUX_SOCKET=<roster socket or empty>
|
||||||
```
|
```
|
||||||
|
|
||||||
`MOSAIC_GIT_IDENTITY` is derived from and must equal `MOSAIC_AGENT_NAME`; it is not a separate
|
The generated launch contract supports only `claude`, `codex`, `opencode`, and `pi`. fleet add
|
||||||
operator-controlled identity authority. The generated launch contract supports only `claude`, `codex`,
|
uses that same runtime authority and rejects any other runtime before it writes the roster or changes
|
||||||
`opencode`, and `pi`. fleet add uses that same runtime authority and rejects any other runtime before it writes the roster or changes
|
|
||||||
projection, local, or quarantine files. The legacy dogfood stub on its separate `mosaic-factory`
|
projection, local, or quarantine files. The legacy dogfood stub on its separate `mosaic-factory`
|
||||||
socket remains an observability canary; it has no generated-launch adapter and cannot be added through
|
socket remains an observability canary; it has no generated-launch adapter and cannot be added through
|
||||||
this projection path.
|
this projection path.
|
||||||
|
|||||||
@@ -9,9 +9,8 @@
|
|||||||
5. [Adding New MCP Tools](#adding-new-mcp-tools)
|
5. [Adding New MCP Tools](#adding-new-mcp-tools)
|
||||||
6. [Database Schema and Migrations](#database-schema-and-migrations)
|
6. [Database Schema and Migrations](#database-schema-and-migrations)
|
||||||
7. [Claude Code Skill Bridge](#claude-code-skill-bridge)
|
7. [Claude Code Skill Bridge](#claude-code-skill-bridge)
|
||||||
8. [Pi Persistent Goal Extension](#pi-persistent-goal-extension)
|
8. [API Endpoint Reference](#api-endpoint-reference)
|
||||||
9. [API Endpoint Reference](#api-endpoint-reference)
|
9. [Local Fleet Canary](./fleet-local-canary.md)
|
||||||
10. [Local Fleet Canary](./fleet-local-canary.md)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -397,85 +396,6 @@ M1 intentionally manages Claude Code only. Pi's Mosaic launcher can discover the
|
|||||||
canonical root directly. Codex still relies on the existing full skill-sync
|
canonical root directly. Codex still relies on the existing full skill-sync
|
||||||
linker and needs separate parity analysis before this lifecycle API is extended.
|
linker and needs separate parity analysis before this lifecycle API is extended.
|
||||||
|
|
||||||
## Pi Persistent Goal Extension
|
|
||||||
|
|
||||||
The source of the Mosaic-owned Pi goal controller is:
|
|
||||||
|
|
||||||
```text
|
|
||||||
packages/mosaic/framework/runtime/pi/goal-extension.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
The framework manifest classifies `runtime/**` as framework-owned. Both the bash installer and the
|
|
||||||
TypeScript file adapter therefore deploy the same reviewed source to:
|
|
||||||
|
|
||||||
```text
|
|
||||||
$MOSAIC_HOME/runtime/pi/goal-extension.ts
|
|
||||||
# default: ~/.config/mosaic/runtime/pi/goal-extension.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
Do not copy or link this extension into `~/.pi/agent/extensions/`. The launcher function
|
|
||||||
`discoverPiExtensionArgs()` emits the core `mosaic-extension.ts` first and the optional
|
|
||||||
`goal-extension.ts` second, preserving compatibility with an older installed framework that does
|
|
||||||
not have the goal file yet.
|
|
||||||
|
|
||||||
### Lifecycle design
|
|
||||||
|
|
||||||
| Pi API | Goal-controller responsibility |
|
|
||||||
| ------------------------------ | --------------------------------------------------------------------------------- |
|
|
||||||
| `registerCommand('goal')` | Set, inspect, pause, resume, or cancel one branch-specific goal |
|
|
||||||
| `registerTool(...)` | Record a terminating structured progress report with evidence |
|
|
||||||
| `context` | Inject the active goal contract before every provider request |
|
|
||||||
| `turn_end` | Record every turn, reject mixed final reports, and enforce the turn bound |
|
|
||||||
| `agent_settled` | Start one deduplicated continuation only after Pi has no retry/compact/queue work |
|
|
||||||
| `session_compact` | Record the compact check, reset provisional verification, and defer idle work |
|
|
||||||
| `session_start`/`session_tree` | Rebuild state from custom entries on the active branch |
|
|
||||||
| `session_shutdown` | Invalidate deferred callbacks and clear UI state |
|
|
||||||
|
|
||||||
State is appended as `mosaic-goal-state` custom entries, which do not enter model context. The
|
|
||||||
`context` hook creates a fresh hidden `mosaic-goal-context` message for each request instead of
|
|
||||||
trusting compaction summaries. The `mosaic_goal_report` result uses `terminate: true`; when it is the
|
|
||||||
sole final tool call, Pi avoids an unnecessary model response before the controller decides whether
|
|
||||||
to verify, continue, or stop.
|
|
||||||
|
|
||||||
Before state is appended or displayed, the controller applies bounded credential-pattern redaction
|
|
||||||
to the goal statement, report summary/evidence/next step, and stop reason. Fingerprints are computed
|
|
||||||
over redacted report content. Pi session entries are append-only, so a credential-bearing legacy
|
|
||||||
entry cannot honestly be erased by the extension: restoration fails closed, emits a warning, and
|
|
||||||
requires removal of the affected session before setting a new goal. This is defense-in-depth rather
|
|
||||||
than a secret-storage contract, and it does not rewrite Pi's separate model-message/tool-call
|
|
||||||
history. Goal prompts tell the agent not to submit credentials or raw sensitive output, and tests use
|
|
||||||
canaries to prove known forms do not reach new custom entries, status text, context, or tool details
|
|
||||||
while ordinary typed fields such as `token: string` remain intact.
|
|
||||||
|
|
||||||
Completion remains evidence-gated but semantic: two consecutive `achieved` reports are required,
|
|
||||||
and the second run is explicitly a verification pass. This avoids an extra judge-model request after
|
|
||||||
every turn. Deterministic validator commands are intentionally not accepted as `/goal` input in this
|
|
||||||
slice, so never describe this mechanism as proof of arbitrary natural-language completion.
|
|
||||||
|
|
||||||
### Tests and local smoke workflow
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm --filter @mosaicstack/mosaic exec vitest run \
|
|
||||||
src/runtime/pi-goal-extension.spec.ts \
|
|
||||||
src/commands/launch.spec.ts \
|
|
||||||
src/config/file-adapter.test.ts
|
|
||||||
|
|
||||||
bash packages/mosaic/framework/tools/quality/scripts/test-install-migration.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
For an additive local smoke test without reseeding unrelated live framework files:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
install -D -m 0644 \
|
|
||||||
packages/mosaic/framework/runtime/pi/goal-extension.ts \
|
|
||||||
~/.config/mosaic/runtime/pi/goal-extension.ts
|
|
||||||
|
|
||||||
pi --extension ~/.config/mosaic/runtime/pi/goal-extension.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
Use `/goal help`, `/goal set ...`, and `/goal status` in that test session. A released framework
|
|
||||||
sync installs the file, and a released Mosaic CLI loads it automatically through `mosaic pi`.
|
|
||||||
|
|
||||||
## API Endpoint Reference
|
## API Endpoint Reference
|
||||||
|
|
||||||
All endpoints are served by the gateway at `http://localhost:14242` by default.
|
All endpoints are served by the gateway at `http://localhost:14242` by default.
|
||||||
|
|||||||
@@ -4,16 +4,7 @@
|
|||||||
**Tracking:** [Mosaic Stack issue #751](https://git.mosaicstack.dev/mosaicstack/stack/issues/751)
|
**Tracking:** [Mosaic Stack issue #751](https://git.mosaicstack.dev/mosaicstack/stack/issues/751)
|
||||||
**Execution:** USC web1 only; collision-free GPT coder2/3/4/5 lanes
|
**Execution:** USC web1 only; collision-free GPT coder2/3/4/5 lanes
|
||||||
**Contract:** `SHARED-CONTRACT.md` + four `contracts/*.v1.ts` files
|
**Contract:** `SHARED-CONTRACT.md` + four `contracts/*.v1.ts` files
|
||||||
|
**Implementation hold:** no feature slice starts until the canon PR is merged to `main` with terminal-green CI; after merge, each slice remains held until every declared KBN prerequisite is complete.
|
||||||
> **HOLD LIFTED — 2026-08-20.** The stated condition ("no feature slice starts until the
|
|
||||||
> canon PR is merged to `main` with terminal-green CI") was **met on 2026-07-14** by
|
|
||||||
> `49e8a541` — _docs(#751): Publish native Kanban/SOT canon (#752)_ — which is on both
|
|
||||||
> `main` and `next`, and `docs/requirements/native-kanban-sot.md` is present on `main`.
|
|
||||||
> The line below was never updated, so for five weeks this file advertised a blocker that
|
|
||||||
> did not exist. The status line above ("PUBLICATION IN PROGRESS") is stale for the same
|
|
||||||
> reason. This file is a build plan, not a task tracker, and is NOT superseded.
|
|
||||||
|
|
||||||
**Implementation hold (SATISFIED — see note above):** no feature slice starts until the canon PR is merged to `main` with terminal-green CI; after merge, each slice remains held until every declared KBN prerequisite is complete.
|
|
||||||
|
|
||||||
> This publication file is not a runtime task authority. After cutover, repository `TASKS.md` is generated read-only and never imported.
|
> This publication file is not a runtime task authority. After cutover, repository `TASKS.md` is generated read-only and never imported.
|
||||||
|
|
||||||
|
|||||||
@@ -1,83 +0,0 @@
|
|||||||
# #1099 pipefail + early-exit sweep
|
|
||||||
|
|
||||||
Baseline: `df4c591ab42aa1ae62c12935fdc0e772684864a0`
|
|
||||||
|
|
||||||
This is a site inventory, not a risk count. `FIXED` means the early-exiting consumer no longer has a piped upstream process whose SIGPIPE can become the result under `pipefail`. `NOT-LOAD-BEARING` means the pipeline status is explicitly discarded. `UNREACHABLE-AND-WHY` describes designed input, not a payload-size safety claim.
|
|
||||||
|
|
||||||
## Tranche 1 — runtime and general scripts
|
|
||||||
|
|
||||||
| Baseline site | Verdict | Construction / reason |
|
|
||||||
| ------------------------------------------------------------------- | ------- | ---------------------------------------------------------------------------------------- |
|
|
||||||
| `tools/matrix-presence-harness/run.sh:38` | FIXED | nullglob array selects the first path; no pipeline |
|
|
||||||
| `tools/e2e-install-test.sh:139` | FIXED | capture help completely, then grep via redirection |
|
|
||||||
| `tools/install.sh:312` | FIXED | NUL `mapfile` reads all roots; count != 1 reaches the named malformed-archive diagnostic |
|
|
||||||
| `scripts/analysis/reflect-board-history.sh:76` | FIXED | capture Git history completely, then grep via redirection |
|
|
||||||
| `scripts/analysis/reflect-git-history.sh:67` | FIXED | grep reads from a here-string |
|
|
||||||
| `scripts/analysis/reflect-git-history.sh:69` | FIXED | grep reads from a here-string |
|
|
||||||
| `packages/mosaic/framework/tools/authentik/user-create.sh:72` | FIXED | jq `first(...)` reads the response directly |
|
|
||||||
| `packages/mosaic/framework/tools/git/mutate-push-guard.sh:87` | FIXED | grep `-m1` reads the file directly; downstream `cut` consumes its complete scalar output |
|
|
||||||
| `packages/mosaic/framework/tools/orchestrator/session-resume.sh:94` | FIXED | `mapfile` plus bounded indexed loop replaces `head` pipeline |
|
|
||||||
| `packages/mosaic/framework/tools/prdy/prdy-status.sh:69` | FIXED | grep reads from a here-string |
|
|
||||||
| `packages/mosaic/framework/tools/qa/reflect-stop-hook.sh:172` | FIXED | grep reads from a here-string |
|
|
||||||
| `packages/mosaic/framework/tools/qa/reflect-stop-hook.sh:173` | FIXED | grep reads from a here-string |
|
|
||||||
| `packages/mosaic/framework/tools/qa/reflect-stop-hook.sh:174` | FIXED | grep reads from a here-string |
|
|
||||||
| `packages/mosaic/framework/tools/qa/reflect-stop-hook.sh:175` | FIXED | grep reads from a here-string |
|
|
||||||
| `packages/mosaic/framework/tools/qa/reflect-stop-hook.sh:176` | FIXED | grep reads from a here-string |
|
|
||||||
| `packages/mosaic/framework/tools/qa/reflect-stop-hook.sh:177` | FIXED | grep reads from a here-string |
|
|
||||||
| `packages/mosaic/framework/tools/qa/reflect-stop-hook.sh:178` | FIXED | grep reads from a here-string |
|
|
||||||
| `packages/mosaic/framework/tools/qa/typecheck-hook.sh:16` | FIXED | Bash regex extracts the first field without a pipeline |
|
|
||||||
| `packages/mosaic/framework/tools/qa/typecheck-hook.sh:56` | FIXED | grep and bounded sed each read from a here-string |
|
|
||||||
| `packages/mosaic/framework/tools/tmux/send-message.sh:113` | FIXED | grep reads from a here-string |
|
|
||||||
| `packages/mosaic/framework/tools/tmux/send-message.sh:124` | FIXED | grep reads from a here-string |
|
|
||||||
| `packages/mosaic/framework/tools/wake/detector.sh:126` | FIXED | one awk reads the manifest directly and exits after the first exact key |
|
|
||||||
| `packages/mosaic/framework/tools/wake/detector.sh:270` | FIXED | grep reads from a here-string |
|
|
||||||
| `packages/mosaic/framework/tools/wake/detector.sh:278` | FIXED | grep reads from a here-string |
|
|
||||||
| `packages/mosaic/framework/tools/wake/digest.sh:647` | FIXED | capture complete locator output, then select first line by parameter expansion |
|
|
||||||
| `packages/mosaic/framework/tools/wake/reconcile.sh:149` | FIXED | one awk reads the manifest directly and exits after the first exact key |
|
|
||||||
|
|
||||||
## Explicit withdrawn / non-load-bearing sites
|
|
||||||
|
|
||||||
| Baseline site | Verdict | Reason |
|
|
||||||
| ---------------------------------------------------------------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------- | --- | --------------------------------------- |
|
|
||||||
| `tools/install.sh:182` | NOT-LOAD-BEARING | ` | | true` explicitly discards lookup status |
|
|
||||||
| `tools/install.sh:356` | UNREACHABLE-AND-WHY | `pnpm pack` writes one matching CLI tarball into a fresh directory immediately before lookup; citation withdrawn in #1099 |
|
|
||||||
| `tools/install.sh:357` | UNREACHABLE-AND-WHY | same fresh-directory invariant for gateway tarball; citation withdrawn in #1099 |
|
|
||||||
| `tools/install.sh:627` | NOT-LOAD-BEARING | ` | | true` explicitly discards lookup status |
|
|
||||||
| `scripts/agent/session-start.sh:70` | NOT-LOAD-BEARING | optional scratchpad lookup has ` | | true` |
|
|
||||||
| `packages/mosaic/framework/templates/repo/scripts/agent/session-start.sh:58` | NOT-LOAD-BEARING | optional scratchpad lookup has ` | | true` |
|
|
||||||
| `packages/mosaic/framework/tools/qa/qa-hook-stdin.sh:25` | UNREACHABLE-AND-WHY | withdrawn in #1099 after designed-input reachability measurement; preserved without re-litigation |
|
|
||||||
| `packages/mosaic/framework/tools/qa/qa-hook-stdin.sh:27` | UNREACHABLE-AND-WHY | same withdrawn designed-input finding |
|
|
||||||
| `packages/mosaic/framework/tools/qa/qa-hook-stdin.sh:30` | UNREACHABLE-AND-WHY | same withdrawn designed-input finding |
|
|
||||||
| `packages/mosaic/framework/tools/qa/qa-hook-stdin.sh:32` | UNREACHABLE-AND-WHY | same withdrawn designed-input finding |
|
|
||||||
| `packages/mosaic/framework/tools/qa/qa-hook-stdin.sh:34` | UNREACHABLE-AND-WHY | same withdrawn designed-input finding |
|
|
||||||
|
|
||||||
## Tranche 2 — non-wake test harnesses
|
|
||||||
|
|
||||||
All 22 baseline sites below are `FIXED`; the checked-in tranche fixture is passed through the same scanner and asserts all 22 occurrences and 21 normalized identities (the same response-split line occurs twice).
|
|
||||||
|
|
||||||
| Baseline site(s) | Verdict | Construction |
|
|
||||||
| ------------------------------------------------------ | ------- | ------------------------------------------------------------- |
|
|
||||||
| `systemd/user/test-fleet-units.sh:148` | FIXED | capture tmux output, then grep via redirection |
|
|
||||||
| `git/test-issue-comment-readback.sh:283,302` | FIXED | parameter expansion splits status/body without `head` |
|
|
||||||
| `git/test-pr-review-gitea-comment.sh:228` | FIXED | parameter expansion splits status/body |
|
|
||||||
| `git/test-lane-brief-pr-linkage.sh:72` | FIXED | grep reads from a here-string |
|
|
||||||
| `git/test-pr-review-repo-host-override.sh:225-226` | FIXED | grep reads from a here-string |
|
|
||||||
| `orchestrator/smoke-test.sh:67,72` | FIXED | parameter expansion selects first line |
|
|
||||||
| `orchestrator/test-board-roll.sh:99-100` | FIXED | grep reads from a here-string |
|
|
||||||
| `quality/scripts/test-upgrade-durable-snapshot.sh:180` | FIXED | complete sorted output is read with `mapfile`, then indexed |
|
|
||||||
| `quality/scripts/test-upgrade-rollback.sh:339,356` | FIXED | direct `grep -m1` file reads; cleanup captures before testing |
|
|
||||||
| `tmux/test-send-message-socket.sh:37,38,44-46,68,72` | FIXED | capture commands complete before redirected grep assertions |
|
|
||||||
| `tmux/test-send-message-verdict.sh:34` | FIXED | grep reads from a here-string |
|
|
||||||
|
|
||||||
## Tranche 3 — wake validation harnesses
|
|
||||||
|
|
||||||
All 26 baseline occurrences (25 normalized identities; one preimage selector occurs twice) are `FIXED` and mechanically bound through the wake fixture and shared scanner.
|
|
||||||
|
|
||||||
| Baseline site(s) | Verdict | Construction |
|
|
||||||
| -------------------------------------------------------------------------------------------- | ------- | -------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| `wake/test-wake-digest-quarantine.sh:567` | FIXED | complete match populations are captured, then first line selected by parameter expansion |
|
|
||||||
| `wake/test-wake-preimage.sh:182-183,346-347` | FIXED | jq `first(...)` reads each JSONL file directly |
|
|
||||||
| `wake/validate-973/microtest-wake-assert.sh:153,170-171,176,204-209,233-234,251-252,286-287` | FIXED | scalar assertions use here-strings; diagnostics use non-early sed ranges; source line captured before matching |
|
|
||||||
| `wake/validate-973/validate-973.sh:110,119,180,182,187` | FIXED | scalar assertions use here-strings; diagnostic truncation uses consuming sed ranges |
|
|
||||||
|
|
||||||
The scoped inventory is complete: 26 runtime/general + 22 non-wake tests + 26 wake tests fixed; 11 explicitly withdrawn or non-load-bearing sites retain their documented verdicts.
|
|
||||||
@@ -1,229 +0,0 @@
|
|||||||
# #1043 — Fleet pane git-identity propagation
|
|
||||||
|
|
||||||
## Objective
|
|
||||||
|
|
||||||
Ensure a fleet seat's launched runtime process receives its roster-derived `MOSAIC_GIT_IDENTITY`, and lock the complete generated-environment propagation boundary with an enumerated set comparison.
|
|
||||||
|
|
||||||
## Tracking
|
|
||||||
|
|
||||||
- External issue: `mosaicstack/stack#1043`
|
|
||||||
- Branch: `fix/1043-pane-git-identity`
|
|
||||||
- Coordinator: `tl-mosaic`
|
|
||||||
- `docs/TASKS.md`: read-only by project worker contract; not modified.
|
|
||||||
|
|
||||||
## Constraints
|
|
||||||
|
|
||||||
- RED-first bug reproducer is mandatory.
|
|
||||||
- R7 delete-the-subject mutation must turn the behavioral test red.
|
|
||||||
- Assert launched-process environment, not source text.
|
|
||||||
- One push only; do not poll CI after push.
|
|
||||||
- Run the CI queue guard immediately before push and report its `state=` line as state, not evidence.
|
|
||||||
- Do not modify a live host launcher or obtain/copy another credential.
|
|
||||||
- Self-post the PR, verify provider attribution, then stop.
|
|
||||||
- Final status wording: `believed-fixed, pending jarvis validation`.
|
|
||||||
|
|
||||||
## Scope inventory
|
|
||||||
|
|
||||||
Re-derived against `origin/main` at `85d2108e`:
|
|
||||||
|
|
||||||
- Launch consumer: `packages/mosaic/framework/tools/fleet/start-agent-session.sh`
|
|
||||||
- Behavioral launch test: `packages/mosaic/framework/tools/fleet/test-start-agent-session.sh`
|
|
||||||
- Generated-environment contract/parser: `packages/mosaic/src/fleet/generated-env-boundary.ts`
|
|
||||||
- Roster projection producers:
|
|
||||||
- `packages/mosaic/src/commands/fleet.ts`
|
|
||||||
- `packages/mosaic/src/fleet/fleet-reconciler.ts`
|
|
||||||
- `packages/mosaic/src/fleet/fleet-agent-crud.ts`
|
|
||||||
- `packages/mosaic/src/fleet/v1-v2-migration.ts`
|
|
||||||
- Contract and producer tests discovered by repository search.
|
|
||||||
- Generated-environment operator/developer docs and their executable documentation contract test.
|
|
||||||
|
|
||||||
Discrepancy sent to `tl-mosaic`: current main no longer contains the charter's `PANE_SHELL_SNIPPET`; #772 replaced it with an `/usr/bin/env -i` argv launch boundary, and current generated projections do not declare git identity. Code-read inventory is **NOT MEASURED** behavior.
|
|
||||||
|
|
||||||
## Plan
|
|
||||||
|
|
||||||
1. Add the process-environment set-comparison regression first and record RED.
|
|
||||||
2. Add roster-derived `MOSAIC_GIT_IDENTITY=<agent name>` to the complete generated projection contract.
|
|
||||||
3. Validate identity syntax and equality with `MOSAIC_AGENT_NAME`; pass it through the clean pane environment.
|
|
||||||
4. Update affected projection tests and generated-environment docs.
|
|
||||||
5. Run focused and baseline gates.
|
|
||||||
6. Perform R7 by deleting the pane propagation entry, prove RED, restore, and prove GREEN.
|
|
||||||
7. Run independent review, remediate, commit, queue guard, one push, self-post PR, verify provider attribution, and stop without CI polling.
|
|
||||||
|
|
||||||
## Budget
|
|
||||||
|
|
||||||
No explicit token cap was provided. Working cap: one narrow logical unit, no dependency installation unless existing tooling requires it, no unrelated refactor.
|
|
||||||
|
|
||||||
## Evidence log
|
|
||||||
|
|
||||||
### TDD and mutation evidence
|
|
||||||
|
|
||||||
- RED-first, repository launcher: `bash packages/mosaic/framework/tools/fleet/test-start-agent-session.sh` exited 64 on pre-fix source with `code=unknown-key key=MOSAIC_GIT_IDENTITY`. The generated seat could not launch with the required declared identity.
|
|
||||||
- GREEN: the same repository launcher test emitted `ok - start-agent-session generated environment boundary`.
|
|
||||||
- R7 delete-the-subject: removed only `"MOSAIC_GIT_IDENTITY=$MOSAIC_GIT_IDENTITY"` from the repository launch array; the same test exited 1 with `FAIL: runtime pane omitted or changed generated environment keys: MOSAIC_GIT_IDENTITY`.
|
|
||||||
- R7 restoration: restored that launch entry; the same test returned green.
|
|
||||||
- Launcher under test is explicitly `packages/mosaic/framework/tools/fleet/start-agent-session.sh` through the test's `$START`, **not** the stale installed host copy.
|
|
||||||
|
|
||||||
### Situational and focused tests
|
|
||||||
|
|
||||||
- Repository launcher boundary: green, including set comparison of all nine generated projection entries and fail-before-tmux cases for missing, unsafe, mismatched, and local-shadow Git identity.
|
|
||||||
- Fleet systemd launcher integration: `bash packages/mosaic/framework/systemd/user/test-fleet-units.sh` — green.
|
|
||||||
- Focused Mosaic Vitest set: 6 files, 311 tests — green.
|
|
||||||
- `bash -n` on changed shell files — green.
|
|
||||||
- `git diff --check` — green.
|
|
||||||
|
|
||||||
### Baseline gates
|
|
||||||
|
|
||||||
- `pnpm typecheck` — 45/45 tasks green.
|
|
||||||
- `pnpm lint` — 25/25 tasks green.
|
|
||||||
- `pnpm format:check` — green.
|
|
||||||
- `pnpm test:checkout` — green.
|
|
||||||
- Repository-wide Vitest under a hermetic current-version npm prefix: Mosaic 81/81 files and 1510/1510 tests green; other workspace test tasks shown green before the framework-shell phase.
|
|
||||||
- Canonical `pnpm test` is not fully green on this host for unrelated environment-sensitive gates:
|
|
||||||
1. the first two runs exposed the globally installed Mosaic 0.0.48 update banner in three CLI smoke tests expecting empty stderr;
|
|
||||||
2. after isolating that global-version input, the framework wake assertion aborted at the known `#973` Bash `BASH_LINENO` convention check (exit 97; observed `[3 5]`, expected `[3 4]`).
|
|
||||||
No tests were weakened or bypassed; focused changed-surface tests are green. CI remains the canonical clean-environment result and is intentionally not polled after push per charter.
|
|
||||||
|
|
||||||
### Independent review
|
|
||||||
|
|
||||||
- Codex code review first pass: request changes for missing shell rejection-path coverage.
|
|
||||||
- Remediation: added table-driven missing/unsafe/mismatch/local-shadow launcher cases, each asserting no tmux call.
|
|
||||||
- Codex code re-review: **approve**, no findings, confidence 0.88.
|
|
||||||
- Codex security review: risk `none`, no findings, confidence 0.97.
|
|
||||||
|
|
||||||
### Acceptance criteria mapping
|
|
||||||
|
|
||||||
| Acceptance criterion | Evidence |
|
|
||||||
| --- | --- |
|
|
||||||
| AC-FGI-01: launched process receives every generated key/value | Repository launcher process-environment `comm -23` set comparison; GREEN and R7 RED evidence above |
|
|
||||||
| AC-FGI-02: missing, unsafe, or split identity fails before tmux | Table-driven shell cases plus TypeScript generated-boundary tests |
|
|
||||||
| AC-FGI-03: focused/baseline/review evidence recorded | Commands and review outcomes above; host-sensitive full-suite limitations stated explicitly |
|
|
||||||
|
|
||||||
### Documentation checklist
|
|
||||||
|
|
||||||
- PRD updated with #1043 requirements and acceptance criteria.
|
|
||||||
- Fleet launch runbook, generated-env concept, and generated-env reference updated.
|
|
||||||
- No API/OpenAPI, sitemap, user publishing target, deployment, or external docs publication change applies.
|
|
||||||
- `docs/TASKS.md` remains unmodified per its single-writer project contract.
|
|
||||||
|
|
||||||
## Round 2 — PR #1073 review 97 remediation
|
|
||||||
|
|
||||||
### Review blocker
|
|
||||||
|
|
||||||
The launched-process suite was signed-excluded from CI enumeration. Manual GREEN/R7 evidence therefore did not prove a PR workflow could detect regression.
|
|
||||||
|
|
||||||
### RED-first and canonical wiring
|
|
||||||
|
|
||||||
1. Removed the suite's signed exclusion before adding a CI execution path.
|
|
||||||
2. `check-test-enumeration.sh` went RED with exact `UNENUMERATED` output for `test-start-agent-session.sh`: population 49, enumerated 30, excluded 18.
|
|
||||||
3. Added both `framework/tools/fleet/test-start-agent-session.sh` and `framework/systemd/user/test-fleet-units.sh` to `@mosaicstack/mosaic`'s canonical `test:framework-shell` chain.
|
|
||||||
4. The guard returned GREEN: population 49, enumerated 32, excluded 18, surfaces 45. The systemd suite is outside the guard's tools-only population but now has the same explicit canonical execution disposition.
|
|
||||||
|
|
||||||
### Workflow-level R7
|
|
||||||
|
|
||||||
- Deleted only the pane launch entry `"MOSAIC_GIT_IDENTITY=$MOSAIC_GIT_IDENTITY"`.
|
|
||||||
- Ran the exact `.woodpecker/ci.yml` test-step command, `pnpm test`, with only a temporary PATH-scoped npm shim reporting the checkout's current 0.0.49 version so the unrelated global 0.0.48 banner could not preempt the shell chain.
|
|
||||||
- Result: exit 1 at `@mosaicstack/mosaic#test`, with the enumeration guard GREEN followed by `FAIL: runtime pane omitted or changed generated environment keys: MOSAIC_GIT_IDENTITY`.
|
|
||||||
- Restored the launch entry. The canonical `test:framework-shell` chain then reached both newly wired suites and printed both GREEN markers before the known unrelated #973 host-only `BASH_LINENO` abort.
|
|
||||||
- An actual provider PR workflow on the intentionally broken mutant is **NOT MEASURED**: the one-push constraint forbids pushing a red mutant and then a repaired head. Local execution proves the exact PR workflow command and dependency chain go RED on the subject deletion; CI on the repaired pushed head remains canonical.
|
|
||||||
|
|
||||||
### Workflow population
|
|
||||||
|
|
||||||
- **DEFINED:** 3 workflows (`ci.yml`, `ci-image.yml`, `publish.yml`).
|
|
||||||
- **ELIGIBLE for `pull_request`:** 1/3 (`ci.yml`), based on top-level `when:` clauses.
|
|
||||||
- **REPORTED:** Round-1 exact-head provider read reported 1/1 eligible context (`ci/woodpecker/pr/ci`). Post-remediation-head reported count is **NOT MEASURED** by this seat because CI polling is prohibited; workflow definitions and eligibility did not change.
|
|
||||||
|
|
||||||
### Independent remediation review
|
|
||||||
|
|
||||||
- First Round-2 review identified a CI-image blocker: the newly wired launcher suite used Perl, which the Alpine CI base does not install.
|
|
||||||
- Replaced the suite's three Perl-only fixture mutations with POSIX/BusyBox-compatible `sed -i` substitutions; production behavior and assertions are unchanged.
|
|
||||||
- Codex re-review: **APPROVE**, confidence 0.93, no findings.
|
|
||||||
|
|
||||||
### Vitest denominator reconciliation
|
|
||||||
|
|
||||||
The PR's `311/311` is correct for its explicitly named six-file command at both the original and remediation worktrees:
|
|
||||||
|
|
||||||
- generated environment boundary: 24
|
|
||||||
- fleet documentation: 23
|
|
||||||
- Tess service profile: 6
|
|
||||||
- fleet regen command: 27
|
|
||||||
- fleet agent CRUD command: 22
|
|
||||||
- fleet command: 209
|
|
||||||
- total: **311**
|
|
||||||
|
|
||||||
Review 97 reported 312/312 without naming its six files. That is a different or miscounted population and cannot replace the command-scoped 311 denominator; the PR follow-up will name the exact files and arithmetic.
|
|
||||||
|
|
||||||
## Round 3 — Alpine stale-marker portability
|
|
||||||
|
|
||||||
### Objective and plan
|
|
||||||
|
|
||||||
- Replace the GNU-only relative-date fixture with a deterministic POSIX/BusyBox timestamp while preserving the required stale-marker assertion.
|
|
||||||
- Re-run the launcher suite in the canonical `ci-base:latest` Alpine image, then run applicable repository gates and independent review.
|
|
||||||
- Update the PR body to name the repeated GNU-host/Alpine-CI portability pattern, run the mandatory queue guard, push once, verify provider attribution, and stop without CI polling.
|
|
||||||
- Working budget: 8K tokens; scope is one fixture line plus delivery evidence. No production behavior changes.
|
|
||||||
|
|
||||||
### RED-first evidence
|
|
||||||
|
|
||||||
Before the fix, the canonical CI image command
|
|
||||||
`docker run --rm -v "$PWD:/work" -w /work git.mosaicstack.dev/mosaicstack/stack/ci-base:latest bash packages/mosaic/framework/tools/fleet/test-start-agent-session.sh`
|
|
||||||
exited 1 at the stale-marker setup with exact BusyBox output
|
|
||||||
`touch: invalid date '10 seconds ago'`. The prior fresh-marker assertions had already executed, matching pipeline 2233's failure location.
|
|
||||||
|
|
||||||
### Root cause and fix
|
|
||||||
|
|
||||||
The test used GNU `touch -d` relative-date parsing although the PR workflow runs on Alpine/BusyBox. The fixture now uses POSIX `touch -t 200001010000.00`, a fixed timestamp that is unconditionally stale; the stale assertion remains mandatory and was not made tolerant of missing timestamp metadata.
|
|
||||||
|
|
||||||
### Structural pattern
|
|
||||||
|
|
||||||
This is the third GNU-host/Alpine-CI portability defect in the lane: GNU `grep` multi-match counting, Perl-only fixture mutation, and GNU `touch -d` date parsing. The repeated cause is shell suites authored on a GNU host but executed in an Alpine CI image; durable prevention belongs in CI-image execution or portability lint, not assertion weakening.
|
|
||||||
|
|
||||||
### GREEN and quality evidence
|
|
||||||
|
|
||||||
- Focused launcher suite in `ci-base:latest`: exit 0, `ok - start-agent-session generated environment boundary`.
|
|
||||||
- Canonical test step in `ci-base:latest` with the pipeline's `pgvector/pgvector:pg17` service, readiness check, migration, and `pnpm test`: exit 0; 46/46 Turbo tasks; Mosaic 81/81 files and 1510/1510 tests; Gateway 57 passed/5 skipped files and 629 passed/11 skipped tests; enumeration 49 population / 32 enumerated / 18 signed exclusions / 45 named surfaces.
|
|
||||||
- The first image-only `pnpm test` attempt lacked the pipeline PostgreSQL service and failed only on connection refusal after the launcher suite was GREEN. The rerun supplied the canonical service precondition and passed.
|
|
||||||
- Canonical-image baseline: typecheck 45/45 tasks, lint 25/25 tasks, format check GREEN; `git diff --check` GREEN.
|
|
||||||
- Independent Codex code review: APPROVE, confidence 0.96, 2/2 Round-3 files, no findings.
|
|
||||||
- Independent Codex security review: risk none, confidence 0.99, 2/2 Round-3 files, no findings.
|
|
||||||
|
|
||||||
### Re-derived inventory and denominators
|
|
||||||
|
|
||||||
- Round-3 git delta: **2/2 files** — launcher suite and task scratchpad; 25 insertions / 1 deletion before evidence finalization.
|
|
||||||
- Full PR path inventory against `origin/main` at `85d2108e`: **19/19 changed paths**; Round 3 adds no new PR path.
|
|
||||||
- Workflow definition population: **1/3 pull-request-eligible** (`ci.yml` of `ci.yml`, `ci-image.yml`, `publish.yml`).
|
|
||||||
- Do not re-litigate the settled 311/312 populations; both are valid for their separately named Tess6 and CRUD-core7 sets.
|
|
||||||
|
|
||||||
## Round 4 — bound stale-marker observation
|
|
||||||
|
|
||||||
### Objective and plan
|
|
||||||
|
|
||||||
- Make the heartbeat assertion discriminate an initially stale native marker from a fresh marker without changing the production staleness threshold or shortening the polling window.
|
|
||||||
- Freeze only the sidecar's numeric observation clock during the stale-fixture arm so elapsed assertion time cannot turn a fresh mutant stale.
|
|
||||||
- Prove two independent mutants RED: disable production stale-marker detection while retaining the stale fixture; replace the stale fixture with a fresh marker. Restore the tree and prove GREEN in the canonical Alpine image.
|
|
||||||
- Re-derive the changed-path inventory, run applicable quality and independent review gates, commit with environment-only author/committer identity, queue-guard, push once, verify provider attribution using curl stdin config, and stop without CI polling.
|
|
||||||
- Working budget: 8K tokens. Scope is the launcher test and its scratchpad evidence; production launcher behavior remains unchanged.
|
|
||||||
|
|
||||||
### Root cause and bounded observation
|
|
||||||
|
|
||||||
The 30 × 0.1-second assertion window overlaps the production `now - marker > interval * 2 + 1` threshold at interval 1. Depending on second boundaries and load, a fresh marker can age past the threshold before the assertion ends. A focused pre-fix fresh-mutant attempt returned RED while Review 101's full-suite run returned GREEN; the differing result is itself timing dependence, not a discriminating assertion.
|
|
||||||
|
|
||||||
The test now supplies a fixed numeric epoch only to the stale-fixture sidecar. Its real marker mtime is still read from the filesystem, but assertion runtime cannot advance `now`. Date formatting still delegates to the image's real `/bin/date`. Neither the production threshold nor the 30 × 0.1-second polling window changed.
|
|
||||||
|
|
||||||
### Two-mutant RED / restored GREEN
|
|
||||||
|
|
||||||
All three runs used `git.mosaicstack.dev/mosaicstack/stack/ci-base:latest`:
|
|
||||||
|
|
||||||
1. **Stale-detection mutant RED:** replaced only the production stale-age predicate with `false` while retaining the fixed stale marker; suite exit 1 with `FAIL: heartbeat sidecar did not resume after native marker became stale or absent`.
|
|
||||||
2. **Fresh-marker mutant RED:** replaced only `touch -t 200001010000.00` with fresh `touch`; suite exit 1 with the same failed stale-resumption assertion. The fixed observation epoch kept the mutant fresh throughout all 30 polls.
|
|
||||||
3. **Restored tree GREEN:** suite exit 0 with `ok - start-agent-session generated environment boundary`.
|
|
||||||
|
|
||||||
### Re-derived inventory
|
|
||||||
|
|
||||||
- Round-4 delta: **2/2 files** — launcher test plus task scratchpad; production launcher delta is empty.
|
|
||||||
- Full PR inventory against `origin/main`: **19/19 paths**; Round 4 adds no path.
|
|
||||||
- Production stale threshold remains `now - marker > iv * 2 + 1`; assertion polling remains 30 × 0.1 seconds.
|
|
||||||
- Review 101's confirmed enumeration/workflow/CI and attribution evidence is accepted without re-polling or re-derivation.
|
|
||||||
|
|
||||||
## Residual risk
|
|
||||||
|
|
||||||
- Landing on `main` does not update the currently installed host launcher. Host framework installation/reseed and Jarvis live-seat validation are separate downstream events.
|
|
||||||
- Canonical CI result is pending and will not be polled by this seat.
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
# #1098 — Framework shell portability / red main
|
|
||||||
|
|
||||||
## Objective
|
|
||||||
|
|
||||||
Restore terminal-green `main` by making the `test-start-agent-session.sh` clean-environment assertion semantic and portable without removing either newly enumerated framework-shell suite.
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
- Tracking issue: `mosaicstack/stack#1098`
|
|
||||||
- Branch: `fix/framework-shell-portability`
|
|
||||||
- Base: `origin/main` at `4fa2768962702d53e16e8b67ee6ad52ebcb0910e`
|
|
||||||
- Primary file: `packages/mosaic/framework/tools/fleet/test-start-agent-session.sh`
|
|
||||||
- Requirements source: `docs/PRD.md` § Framework shell assertion portability (#1098)
|
|
||||||
- Out of scope: deployed files under `~/.config/mosaic`, pnpm-store cleanup, checkout deletion, and changes to the launcher’s `/usr/bin/env -i` behavior.
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
|
|
||||||
1. The test inspects the captured NUL-delimited tmux argv semantically and accepts an adjacent `/usr/bin/env`, `-i` pair regardless of trailing payload size or pipe scheduling.
|
|
||||||
2. Missing `/usr/bin/env`, missing `-i`, and non-adjacent `-i` remain failures.
|
|
||||||
3. Failure output includes the observed argv records with stable indexes and shell escaping; it exposes no credentials because this fixture supplies only generated non-secret launch data.
|
|
||||||
4. The focused suite passes on the dev host and in the repository CI image; the blocking PR/main pipeline returns terminal green.
|
|
||||||
5. Independent review passes; PR is squash-merged and #1098 is closed only after merged-main CI is terminal green.
|
|
||||||
|
|
||||||
## Budget
|
|
||||||
|
|
||||||
- ASSUMPTION: 30K-token working budget; rationale: one shell-test defect plus full PR/CI lifecycle.
|
|
||||||
- Auto-reduction: focused shell and package gates first; rely on canonical Woodpecker for the full monorepo suite rather than duplicating a dependency install under constrained `/home`.
|
|
||||||
- Disk baseline before clone/build: `/home` 7.1G free (99% used), `/tmp` 2.4G free (92% used).
|
|
||||||
|
|
||||||
## Investigation
|
|
||||||
|
|
||||||
### First-hand CI evidence
|
|
||||||
|
|
||||||
- Public log: `GET https://ci.mosaicstack.dev/api/repos/47/logs/2269/53041`
|
|
||||||
- Decoded 1,436 entries (11 null `data` entries treated as empty log rows), 190,756 bytes.
|
|
||||||
- Failure: `FAIL: pane command did not clear its environment` immediately after the expected pane-PID warning.
|
|
||||||
- BusyBox primitives, complete assertion pipeline, real CI image, stale/current image digests, Turbo cache masking, gateway failure, and heartbeat-sidecar concurrent writing were independently excluded.
|
|
||||||
|
|
||||||
### Root cause
|
|
||||||
|
|
||||||
The assertion ends in:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
printf '%s\n' "$pane_args" | tail -n +"$after_pane_env" | grep -qxF -- '-i'
|
|
||||||
```
|
|
||||||
|
|
||||||
The script has `set -o pipefail`. `grep -q` exits as soon as it finds the valid `-i` record. Upstream `tail`/`printf` can then receive SIGPIPE, making the aggregate pipeline nonzero even though grep returned 0 and the semantic property is true. This depends on payload size, pipe capacity, and scheduling, explaining a local/image pass with a CI failure.
|
|
||||||
|
|
||||||
Discriminating stress control with `/usr/bin/env` followed immediately by `-i`:
|
|
||||||
|
|
||||||
- 8,192-byte trailing payload: `printf=0 tail=0 grep=0`, aggregate 0.
|
|
||||||
- 16,384-byte trailing payload: `printf=0 tail=141 grep=0`, aggregate 141.
|
|
||||||
- 32,768+ bytes: `printf=141 tail=141 grep=0`, aggregate 141.
|
|
||||||
- A full-reading `grep -xF` control remained 0 for every payload.
|
|
||||||
|
|
||||||
This is a third branch omitted by the earlier present-vs-corrupted split: the pair can be present and intact while `pipefail` reports an upstream SIGPIPE.
|
|
||||||
|
|
||||||
## TDD plan
|
|
||||||
|
|
||||||
1. RED: preserve the one-off stress reproducer above and add an automated large-argv semantic regression that fails under the current pipeline implementation.
|
|
||||||
2. GREEN: parse the authoritative NUL-delimited capture into a Bash array and search for an adjacent `/usr/bin/env`, `-i` pair without a short-circuit pipeline.
|
|
||||||
3. Add negative controls for missing, detached, and reversed tokens.
|
|
||||||
4. On failure, print indexed `%q` argv records before returning nonzero.
|
|
||||||
5. Run focused suite, mutation controls, shell syntax/format checks, then repository baseline gates feasible without dependency installation.
|
|
||||||
6. Independent review, queue guard, push, PR, CI, coordinator merge authorization, squash merge, merged-main CI, issue close.
|
|
||||||
|
|
||||||
## Progress
|
|
||||||
|
|
||||||
- [x] Checkout created and based on `origin/main` `4fa27689`.
|
|
||||||
- [x] CI log decoded directly.
|
|
||||||
- [x] Root-cause stress control reproduced semantic match + aggregate pipeline failure.
|
|
||||||
- [x] RED evidence: intact `/usr/bin/env`, `-i` fixture produced component statuses `0/141/0` and aggregate 141 under the former `grep -q` pipeline; full-reading semantic control stayed 0.
|
|
||||||
- [x] GREEN implementation: direct NUL-argv adjacency parser, indexed diagnostics, and full-reading scalar predicates replace all load-bearing early-exit pipelines in this test.
|
|
||||||
- [x] Baseline/situational tests:
|
|
||||||
- focused launcher suite: PASS on GNU host and cached Alpine CI image;
|
|
||||||
- paired `test-fleet-units.sh`: PASS;
|
|
||||||
- enumeration guard: PASS (`population=53`, `enumerated=36`, `excluded=18`), 14/14 mutation needles;
|
|
||||||
- `bash -n`, ShellCheck, `git diff --check`: PASS;
|
|
||||||
- static denominator after change: zero load-bearing `grep -q`/`head`/`-m1` pipeline candidates in `test-start-agent-session.sh`;
|
|
||||||
- delete-the-subject mutation removing production `-i`: RED with 78 indexed argv records, byte count, and explicit boundary failure.
|
|
||||||
- [x] Independent review:
|
|
||||||
- first Codex review: request changes — negative fixtures did not each assert diagnostics;
|
|
||||||
- remediation: centralized predicate + diagnostic wrapper and exercised all four negative fixtures;
|
|
||||||
- second Codex review: APPROVE, 0 blockers/should-fix/suggestions;
|
|
||||||
- Codex security review: risk none, 0 findings.
|
|
||||||
- [ ] PR CI, formal fleet review, merge, merged-main CI, issue closure.
|
|
||||||
|
|
||||||
## Documentation disposition
|
|
||||||
|
|
||||||
- Updated canonical `docs/PRD.md` with FSP requirements and acceptance criteria.
|
|
||||||
- This is an internal test/reliability change with no API, user workflow, deployment, navigation, or publishing-surface change; no user/admin/API/sitemap update is required.
|
|
||||||
- `docs/TASKS.md` remains unchanged because the project contract makes it orchestrator-only.
|
|
||||||
|
|
||||||
## Risks
|
|
||||||
|
|
||||||
- The CI failure did not print its captured argv, so the exact CI payload is unavailable. The stress control proves the assertion is non-portable and can emit the exact false verdict; branch CI is the canonical confirmation that replacing it resolves pipeline 2269’s failure class.
|
|
||||||
- Printing fixture argv is safe only while this test’s projection remains non-secret. The diagnostic must stay scoped to the test capture and shell-escaped.
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
# #1099 — pipefail + early-exit sweep
|
|
||||||
|
|
||||||
## Scope and decisions
|
|
||||||
|
|
||||||
- Baseline `df4c591ab42aa1ae62c12935fdc0e772684864a0`, after #1100 removed its 35 sites.
|
|
||||||
- Split into review-sized non-closing tranches: runtime/general; tmux/git/quality tests; wake validation/tests.
|
|
||||||
- Do not equate class membership with demonstrated risk. Do not use payload size or pipeline stage count as a safety proxy.
|
|
||||||
- Preserve the issue's withdrawn findings for `qa-hook-stdin.sh` and the two fresh-directory `pnpm pack` lookups. Fix `install.sh:312` because malformed multi-root input must reach its named handler.
|
|
||||||
|
|
||||||
## Tranche 1 TDD
|
|
||||||
|
|
||||||
RED-first control: `node --test scripts/pipefail-early-exit.test.mjs` reported exactly 26 non-accepted runtime/general sites, including `install.sh:312`, and exited 1. A checked-in fixture generated from immutable baseline `df4c591a` records all 26 normalized sites; the control passes every fixture entry through the same scanner, asserts exact identity/count/uniqueness, and separately requires zero findings in the current tree. It also inventories accepted sites rather than silently excluding whole files.
|
|
||||||
|
|
||||||
Construction choices:
|
|
||||||
|
|
||||||
- here-string/file redirection for scalar grep assertions;
|
|
||||||
- full capture then parameter expansion for first-line selection;
|
|
||||||
- arrays/`mapfile` for complete populations;
|
|
||||||
- direct jq/awk/grep selection where one tool can express the property;
|
|
||||||
- no `|| true` added to a load-bearing assertion.
|
|
||||||
|
|
||||||
Site-by-site verdicts: `docs/reports/quality/1099-pipefail-sweep.md`.
|
|
||||||
|
|
||||||
## Tranche 2 TDD
|
|
||||||
|
|
||||||
Expanded the unconditional scanner over 11 non-wake test harnesses. RED named exactly 22 source lines; a second immutable-baseline fixture now asserts those 22 entries through the same scanner. Rewrites preserve command status by capturing producers before redirected assertions, use parameter expansion for line selection, and use complete `mapfile` populations where ordering matters. Current-tree finding count is zero for tranches 1 and 2.
|
|
||||||
|
|
||||||
## Tranche 3 TDD
|
|
||||||
|
|
||||||
Expanded the shared scanner over four wake validation harnesses. RED named 26 occurrences. The wake fixture asserts 26 occurrences / 25 normalized identities through the same scanner; all scalar assertions now use redirection, direct jq selection, complete capture, or consuming diagnostic ranges. Current-tree finding count is zero across the full scoped population.
|
|
||||||
|
|
||||||
## Verification so far
|
|
||||||
|
|
||||||
- `bash -n` on every changed shell script: pass.
|
|
||||||
- structural Node control: pass.
|
|
||||||
- `test-mutate-push-guard.sh`: 8/8 pass.
|
|
||||||
- `test-send-message-verdict.sh`: 3/3 pass.
|
|
||||||
- `test-send-message-socket.sh`: pass.
|
|
||||||
- Independent review 143 found two semantic regressions: a help-probe `|| true` changed the failure truth table, and an unguarded Git capture changed non-Git data-dir behavior from rc 0 + JSON to silent rc 128. Both received RED-first regressions before correction; help status is now separate and required, and Git status remains condition-guarded.
|
|
||||||
- Wake static inventory remains aligned at 261/261 after line-neutral rewrites; no static-set mismatch. Wake detector/reconcile/digest/preimage suites terminate at their existing fail-closed #973 `BASH_LINENO` environment probe (exit 97, observed `[3 5]`, expected `[3 4]`) before subject tests. No bypass or skip was used; canonical CI remains required.
|
|
||||||
- ShellCheck reports only pre-existing source-following, unused-variable, and untouched `ls | head` findings; no new diagnostic was introduced.
|
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
# #1150 — Pi persistent goal extension
|
|
||||||
|
|
||||||
- **Task ID:** ISSUE-1150 (no `docs/TASKS.md` row; that file is orchestrator-only)
|
|
||||||
- **Issue:** #1150 — `pi: add persistent /goal controller extension to Mosaic framework`
|
|
||||||
- **Branch:** `feat/1150-pi-goal-extension`
|
|
||||||
- **Mode:** Delivery
|
|
||||||
- **Status:** in progress
|
|
||||||
|
|
||||||
## Objective
|
|
||||||
|
|
||||||
Build and locally validate a Mosaic-owned Pi `/goal` extension. Source must ship from
|
|
||||||
`packages/mosaic/framework/runtime/pi/`, framework sync must deploy it under
|
|
||||||
`~/.config/mosaic/runtime/pi/`, and no extension/configuration asset may be written into `~/.pi`.
|
|
||||||
Pi's native session manager remains the owner of session entries.
|
|
||||||
|
|
||||||
## Scope and acceptance source
|
|
||||||
|
|
||||||
- Canonical requirements: `docs/PRD.md`, section **Pi Persistent Goal Loop (#1150)**.
|
|
||||||
- User intent: continuous goal orientation and status checking after each Pi turn and compaction,
|
|
||||||
tested locally before framework delivery.
|
|
||||||
- Documentation target: canonical in-repo user/developer/runtime docs; no external publication.
|
|
||||||
|
|
||||||
## Assumptions
|
|
||||||
|
|
||||||
- `ASSUMPTION:` Initial semantic verification uses two consecutive structured, evidence-bearing
|
|
||||||
reports from the working agent rather than a second model request after every turn. This keeps the
|
|
||||||
loop testable and avoids doubling model cost while making the limitation explicit.
|
|
||||||
- `ASSUMPTION:` Default autonomous bounds are 40 turns and 6 repeated no-progress reports, with only
|
|
||||||
bounded numeric environment overrides.
|
|
||||||
- `ASSUMPTION:` A local smoke copy to `~/.config/mosaic/runtime/pi/goal-extension.ts` is authorized by
|
|
||||||
the user's explicit request. Full framework reseed into the live home is not required for the smoke
|
|
||||||
test and would touch unrelated framework-owned files.
|
|
||||||
|
|
||||||
## Budget
|
|
||||||
|
|
||||||
- Working estimate: 30K implementation/review tokens.
|
|
||||||
- Hard user cap: none stated.
|
|
||||||
- Cost control: deterministic fake-Pi tests; no nested evaluator calls; only bounded arithmetic/load
|
|
||||||
smoke workflows against the installed runtime.
|
|
||||||
|
|
||||||
## Plan
|
|
||||||
|
|
||||||
1. Update PRD and create tracking/scratchpad artifacts.
|
|
||||||
2. Read launcher, installer ownership, Pi extension, and documentation surfaces.
|
|
||||||
3. TDD: add fake-Pi behavior tests for commands, state restoration, turn checks, compaction, limits,
|
|
||||||
verification, and continuation deduplication.
|
|
||||||
4. Implement `runtime/pi/goal-extension.ts` and deterministic launcher discovery.
|
|
||||||
5. Add framework-sync/deployment acceptance coverage.
|
|
||||||
6. Update user, developer, runtime, framework README, and sitemap documentation.
|
|
||||||
7. Run focused tests, local Mosaic-path smoke test, then baseline repository gates.
|
|
||||||
8. Run independent review, remediate, commit, push/PR/CI/merge/issue closure per delivery gates.
|
|
||||||
|
|
||||||
## TDD decision
|
|
||||||
|
|
||||||
Applied. The continuation state machine and lifecycle scheduling are control-path logic where a race
|
|
||||||
or false terminal state can cause unbounded work or premature completion.
|
|
||||||
|
|
||||||
## Progress checkpoints
|
|
||||||
|
|
||||||
- [x] Issue #1150 created through Mosaic wrapper.
|
|
||||||
- [x] Isolated worktree created from `origin/main`.
|
|
||||||
- [x] PRD requirements and acceptance criteria added.
|
|
||||||
- [x] Task scratchpad created.
|
|
||||||
- [x] RED controller and security-regression tests written and observed failing before implementation.
|
|
||||||
- [x] Goal controller, launcher discovery, framework deployment coverage, and bounded state machine
|
|
||||||
implemented.
|
|
||||||
- [x] User, admin, developer, runtime, adapter, README, and sitemap documentation updated.
|
|
||||||
- [x] Final source copied additively to `~/.config/mosaic/runtime/pi/goal-extension.ts`; source and
|
|
||||||
deployed SHA-256 are identical.
|
|
||||||
- [x] Live Pi RPC smoke from the exact Mosaic path reached `achieved` with two verification passes and
|
|
||||||
no extension errors.
|
|
||||||
- [x] Baseline and situational checks completed, except the explicitly documented unavailable
|
|
||||||
PostgreSQL-only root integration case.
|
|
||||||
- [x] Independent code and OWASP/security reviews completed; all findings remediated and re-reviewed.
|
|
||||||
- [ ] Commit, push, PR, terminal-green CI, squash merge, and issue closure complete.
|
|
||||||
|
|
||||||
## Tests and evidence
|
|
||||||
|
|
||||||
### Situational
|
|
||||||
|
|
||||||
- `pnpm --filter @mosaicstack/mosaic exec vitest run src/runtime/pi-goal-extension.spec.ts`
|
|
||||||
- final: 25 passed.
|
|
||||||
- Covers commands, per-turn checks, context injection, two-pass verification, mixed-report
|
|
||||||
rejection, bounded limits, compaction, branch restore, stale timers, credential redaction,
|
|
||||||
typed-field false-positive protection, and append-only legacy-state fail-closed behavior.
|
|
||||||
- Final focused launcher/controller/file-adapter run: 3 files / 67 tests passed.
|
|
||||||
- Final V8 coverage for `framework/runtime/pi/goal-extension.ts`:
|
|
||||||
- 99.17% statements/lines, 93.78% branches, 100% functions.
|
|
||||||
- Installer migration fixture: 24 passed and byte-compared the deployed framework asset.
|
|
||||||
- Standalone extension TypeScript check against installed Pi 0.84.1 types passed:
|
|
||||||
`pnpm --filter @mosaicstack/mosaic exec tsc --noEmit --pretty false --module NodeNext
|
|
||||||
--moduleResolution NodeNext --target ES2022 --skipLibCheck framework/runtime/pi/goal-extension.ts`.
|
|
||||||
- Live deployment/load evidence:
|
|
||||||
- source/deployed SHA-256:
|
|
||||||
`1f0a3806e0948ad5f49684273a7e535e9880c148f7fd16d13ee487fcd601f637`.
|
|
||||||
- `get_commands` identified `/goal` as an extension command sourced from
|
|
||||||
`~/.config/mosaic/runtime/pi/goal-extension.ts`; `/goal help` succeeded; zero extension errors.
|
|
||||||
- live arithmetic goal ended `achieved`, verification `2/2`, with 3 goal reports / 3 agent starts
|
|
||||||
and zero extension errors.
|
|
||||||
- no goal extension exists under `~/.pi` extension paths.
|
|
||||||
|
|
||||||
### Baseline
|
|
||||||
|
|
||||||
- `pnpm build`: passed before the final framework-only redaction remediation; the extension is not a
|
|
||||||
package build input and its final source passed the standalone Pi type check.
|
|
||||||
- `pnpm typecheck`: 45/45 tasks passed.
|
|
||||||
- `pnpm lint`: 25/25 tasks passed.
|
|
||||||
- `pnpm format:check`: passed.
|
|
||||||
- Final Mosaic package components:
|
|
||||||
- Vitest: 82 files / 1,539 tests passed.
|
|
||||||
- full `test:framework-shell` harness passed.
|
|
||||||
- the discovered pre-existing tmux loader-marker race was reproduced with constructor PID
|
|
||||||
evidence, fixed with a pane readiness/FIFO barrier, passed 3 consecutive focused runs, and passed
|
|
||||||
in the full shell harness.
|
|
||||||
- one combined rerun encountered the separate existing real-lease probe TOCTOU in
|
|
||||||
`install-ordering-guard.spec.ts`; an earlier final Vitest run was fully green and the changed
|
|
||||||
focused suites remained green.
|
|
||||||
- Gateway safe baseline excluding the prohibited PostgreSQL-only fixture: 55 files / 600 tests passed
|
|
||||||
(6 files / 12 tests skipped by their existing environment gates).
|
|
||||||
- Root `pnpm test` reached 43 successful workspace tasks and all changed-package Vitest tests, but
|
|
||||||
the unchanged `apps/gateway/src/__tests__/cross-user-isolation.test.ts` afterAll hook retried a
|
|
||||||
PostgreSQL connection and failed authentication (`28P01`). This checkout explicitly forbids local
|
|
||||||
PostgreSQL startup/access; the failure is unrelated to #1150 and cannot be remediated by starting
|
|
||||||
the database. The gateway suite excluding that PostgreSQL-only file and required CI are used as
|
|
||||||
the safe verification paths.
|
|
||||||
|
|
||||||
### Independent review
|
|
||||||
|
|
||||||
- Codex code review: approved, 0 findings across 15 files.
|
|
||||||
- Initial Codex security review: one medium CWE-532/A09 finding for raw report persistence.
|
|
||||||
- Remediation added central credential-pattern redaction, prompt/docs guidance, canary tests, typed
|
|
||||||
field false-positive guards, and sticky fail-closed restore for credential-bearing append-only
|
|
||||||
history.
|
|
||||||
- Codex security re-review: risk `none`, 0 findings, confidence 0.87.
|
|
||||||
- Focused remediation review findings were fixed; final focused re-review verdict: `APPROVE`.
|
|
||||||
- Focused independent review of the tmux readiness barrier: `APPROVE`, no actionable findings.
|
|
||||||
|
|
||||||
## Risks and blockers
|
|
||||||
|
|
||||||
- Live `~/.config/mosaic` is shared by active Pi/fleet processes. Local deployment remained a single
|
|
||||||
additive framework file and did not reload or restart unrelated sessions.
|
|
||||||
- Completion verification is semantic, not mathematical: the active agent supplies structured
|
|
||||||
evidence twice. Operators must still inspect consequential outcomes.
|
|
||||||
- Credential redaction is pattern-based defense-in-depth, not a secret store. It covers
|
|
||||||
controller-owned state/status/tool details, not Pi's separate model-message/tool-call history.
|
|
||||||
Goals and reports must never contain real secrets or raw sensitive output. Because Pi session
|
|
||||||
entries are append-only, a detected credential-bearing legacy branch fails closed and the affected
|
|
||||||
session must be removed.
|
|
||||||
- Current installed Pi is newer than the repository's historical gateway Pi dependency. The
|
|
||||||
extension was checked and smoke-tested against installed Pi 0.84.1 using stable documented APIs.
|
|
||||||
- Local root testing cannot safely execute the unchanged PostgreSQL-only integration fixture under
|
|
||||||
the checkout's explicit database safety constraints. Terminal-green PR CI remains mandatory before
|
|
||||||
merge.
|
|
||||||
- The unchanged real-lease default-probe test can observe different broker availability across its two
|
|
||||||
sequential probes; one combined package rerun hit that existing TOCTOU. The same final Vitest suite
|
|
||||||
passed in a separate run, and CI remains the merge authority.
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
# #1174 — Wrapper guard rounds 10–11
|
|
||||||
|
|
||||||
## Objective
|
|
||||||
|
|
||||||
Make checkout enforcement judge Git placement operands rather than every HOME-shaped word in the command, without reopening `--separate-git-dir` placement under HOME.
|
|
||||||
|
|
||||||
## Plan
|
|
||||||
|
|
||||||
1. Reproduce the four over-blocks and the placement-option control at head `20d86e39`.
|
|
||||||
2. Add RED fixtures before production changes.
|
|
||||||
3. Extract clone/worktree placement operands from the existing shell-aware normalized stream.
|
|
||||||
4. Run the full guard corpus, historical-head discrimination, syntax/static checks, probes, review, and CI.
|
|
||||||
|
|
||||||
## Progress and evidence
|
|
||||||
|
|
||||||
- Reproduced: `NOTE=$HOME`, `--reference=$HOME`, `GIT_DIR=$HOME/x`, and `--template=$HOME/t` all blocked despite explicit `/src/wt` destinations.
|
|
||||||
- RED at `20d86e39`: expanded suite had 8 failures, all HOME-valued non-placement cases.
|
|
||||||
- GREEN: expanded suite passes 242/242.
|
|
||||||
- Round-10 probes: 7/7 placement expectations and 4/4 placement-option controls pass.
|
|
||||||
- Earlier path probes remain green: 60/60, 24/24, and 17/17.
|
|
||||||
- Historical discrimination with the 242-fixture suite:
|
|
||||||
- `3d0a882a`: 216 pass / 26 fail.
|
|
||||||
- `4b8eba95`: 222 pass / 20 fail.
|
|
||||||
- `20d86e39`: 234 pass / 8 fail.
|
|
||||||
- `bash -n`, ShellCheck warning-or-higher, and `git diff --check`: pass.
|
|
||||||
|
|
||||||
## Residual / risk
|
|
||||||
|
|
||||||
- Relative destinations whose effective path depends on cwd are tracked separately by #1197 and remain out of scope.
|
|
||||||
- Unknown future Git options with a separate following value fail closed when that value is HOME-shaped. This may require classification when Git adds an unrelated path-taking option, but prevents a new placement option from silently bypassing the guard.
|
|
||||||
|
|
||||||
## Round 11 objective and intake
|
|
||||||
|
|
||||||
- **Issue / PR:** #1174.
|
|
||||||
- **Objective:** Remove the finite boolean-flag allowlists that turn accepted clone/worktree flags into fake placement operands, while preserving all real HOME placement blocks.
|
|
||||||
- **Scope:** `wrapper-guard.sh`, its hermetic fixtures, and task documentation. Relative cwd-dependent destinations remain in #1197.
|
|
||||||
- **Surfaces:** security-sensitive Bash hook behavior and shell/Git option grammar; no API, DB, UI, auth, deploy, or dependency changes.
|
|
||||||
- **Budget assumption:** 25K working tokens; reduce exploratory matrices before reducing acceptance coverage.
|
|
||||||
|
|
||||||
### Round 11 plan
|
|
||||||
|
|
||||||
1. Use Git itself to classify accepted/rejected clone and worktree options, and Bash itself to resolve path-word expectations.
|
|
||||||
2. Add RED fixtures for all six reported clone flags, generated negations, and equivalent worktree grammar.
|
|
||||||
3. Replace the open-ended unknown-option fail-closed fallback with a parser based on the closed value-taking option surface; keep explicit placement options special.
|
|
||||||
4. Run the full corpus, historical discrimination, shell/static checks, targeted probes, independent code/security review, one push, and exact-head CI.
|
|
||||||
|
|
||||||
### Root-cause evidence
|
|
||||||
|
|
||||||
- Git 2.39.5 accepts all six reported clone flags and the broader generated family measured in the brief: `--bare`, `--mirror`, `--ipv4`, `--ipv6`, `-4`, `-6`, `--no-local`, `--no-reject-shallow`, `--no-bare`, `--no-sparse`, `--no-dissociate`, `--no-shallow-submodules`, `--no-quiet`, `--no-progress`, and `--no-recurse-submodules`; it rejects `--relative-paths` as unknown.
|
|
||||||
- Git 2.39.5 accepts worktree negations including `--no-force`, `--no-detach`, `--no-lock`, `--no-guess-remote`, and `--no-track`; the current finite worktree flag list does not describe that generated family.
|
|
||||||
- `bash -c "printf '%s' <word>"` resolves `$HOME/source`, `${HOME}/source`, and `"$HOME"/source` under HOME while `/src/wt` remains outside it.
|
|
||||||
- **Hypothesis:** only separate-value options need positive classification. Treat every other option token as a no-value flag unless it is the explicit placement option; this matches Git's non-enumerable boolean family and confines the residual to genuinely new future value-taking options.
|
|
||||||
|
|
||||||
### TDD and verification checkpoints
|
|
||||||
|
|
||||||
- RED against the unmodified `91cc37bc` guard: 253 pass / 22 fail in the initial expanded 275-fixture suite. Failures include all 15 accepted clone flags, accepted long abbreviations, short value-taking bundles, abbreviated placement, worktree metadata abbreviation, and both directions of bundled worktree branch parsing.
|
|
||||||
- An exploratory fail-closed residual test drove emission of every worktree positional. Re-review correctly showed that this over-blocked HOME-shaped commit-ish metadata; a new commit-ish fixture failed RED against that intermediate implementation (278 pass / 2 fail, including one transient message assertion) and the parser was restored to emit only the actual path.
|
|
||||||
- GREEN after remediation: 280/280.
|
|
||||||
- Ultron's 13-shape option probe: 13/13 correct, including the six reported over-blocks, HOME destinations, end-of-options, worktree controls, and a later-command placement.
|
|
||||||
- Round-10 probes remain green: 7/7 subject-placement expectations and 4/4 `--separate-git-dir` controls.
|
|
||||||
- Earlier shell/path probes remain green: 60/60, 24/24, and 17/17.
|
|
||||||
- `bash -n`, ShellCheck warning-or-higher, and `git diff --check`: pass.
|
|
||||||
|
|
||||||
### Deliberate residual
|
|
||||||
|
|
||||||
A future Git release could add a new separate-value option absent from the closed value grammar. It defaults to no-value flag parsing, which leaves the following word positional. For clone, this can fail open if that future option itself creates repository state at its value. For worktree, it can shift which word is read as the path. This hypothetical future ambiguity is accepted deliberately because failing closed on every unclassified option is proven to over-block Git's open-ended present-day boolean/`--no-*` family. Every value-taking and placement option Git currently supports is classified, including accepted abbreviations of `--separate-git-dir`. Relative cwd-dependent targets remain in #1197.
|
|
||||||
|
|
||||||
### Independent review checkpoint
|
|
||||||
|
|
||||||
- Initial Codex code/security review raised `--orphan` as value-taking. Upstream Git `master` contradicts that premise: the synopsis is `[--orphan] [(-b | -B) <new-branch>] <path> [<commit-ish>]`, and the prose derives the branch from the path when `-b`/`-B` is absent. `--orphan` is therefore correctly handled as a boolean flag.
|
|
||||||
- The security review separately identified the generic future worktree shift residual. An attempted fail-closed remediation emitted every positional, but code re-review correctly rejected it because valid grammar has only one placement positional and an optional commit-ish. Final behavior checks only the path and documents the hypothetical future option shift deliberately; paired actual-grammar `--orphan` fixtures cover safe/HOME paths and `-b` metadata.
|
|
||||||
- Security re-review initially had no findings. Code re-review's commit-ish blocker was remediated with a RED fixture and path-only restoration; final code re-review approved with no findings.
|
|
||||||
- Final security review then found non-canonical absolute and symlink aliases. Eight lexical fixtures failed RED against the prior implementation, followed by three symlink fixtures failing RED. Remediation expands only shell-visible HOME tokens, resolves the longest existing directory prefix physically, and lexically normalizes the nonexistent suffix. The suite is now 292/292.
|
|
||||||
- Inherent residual: a symlink can be replaced between pre-tool inspection and Git execution. Existing aliases are resolved; eliminating the race requires enforcement inside the filesystem mutation path rather than a text pre-hook. Security review classified this medium, and architectural closure is tracked in #1199.
|
|
||||||
- Final independent code review: APPROVE, 0 findings. Final security review: no critical/high findings; the single medium TOCTOU residual is explicitly tracked in #1199.
|
|
||||||
|
|
||||||
### Final local evidence
|
|
||||||
|
|
||||||
- Final hermetic suite: 292/292; the same suite against `91cc37bc` discriminates at 256 pass / 36 fail.
|
|
||||||
- Ultron option probe: 13/13; round-10 probes: 7/7 plus 4/4 controls; earlier shell/path probes: 60/60, 24/24, and 17/17.
|
|
||||||
- `bash -n`, ShellCheck warning-or-higher, `git diff --check`, sanitization gate, and test-enumeration gate (population 55; 38 enumerated; 18 signed exclusions): pass.
|
|
||||||
- Independent code review: APPROVE, 0 findings. Security review's remaining medium TOCTOU architecture residual is tracked in #1199; no critical/high findings remain.
|
|
||||||
- Repository-wide TypeScript gates require dependencies absent from this worktree; the canonical Woodpecker pipeline will run them against the pushed exact head.
|
|
||||||
|
|
||||||
### Documentation checklist
|
|
||||||
|
|
||||||
- `docs/PRD.md` updated with WPG requirements, acceptance, canonicalization, and residual risk.
|
|
||||||
- Task scratchpad updated in the same logical change set; `docs/TASKS.md` remains orchestrator-only.
|
|
||||||
- No API, auth, UI, navigation, deployment, user-guide, or admin-guide surface changed; OpenAPI, endpoint index, sitemap, and publishing are not applicable.
|
|
||||||
@@ -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.
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
# #1194 — Installed framework-tool drift detection and refresh analysis
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
The reported queue-guard source defect was already fixed on `main` by `58b971ab`; the live failure came from a stale `~/.config/mosaic/tools/git/ci-queue-wait.sh`. The durable fix is therefore a detector, not a duplicate queue-guard patch.
|
|
||||||
|
|
||||||
`mosaic doctor` now compares the framework tools bundled with the executing Mosaic package against the deployed tools tree. Doctor is the selected visibility boundary because it is observational and operator-invoked: unlike session start, it does not add a repository/network scan to every seat launch, and it cannot silently replace identity or messaging tools while seats are active. It reports drift without changing files. `--fail-on-warn` converts detected drift into a non-zero doctor result.
|
|
||||||
|
|
||||||
## Classification
|
|
||||||
|
|
||||||
The existing `framework-manifest.txt` is authoritative. The detector invokes the canonical shared `tools/_lib/manifest.sh classify` implementation over the complete source census and refuses missing, unreadable, malformed, incomplete, or zero-framework ownership output. Policy is therefore read rather than duplicated:
|
|
||||||
|
|
||||||
- Current policy classifies source files under `tools/**` as framework-owned and required in the deployed tools tree.
|
|
||||||
- Current policy explicitly classifies `tools/_lib/credentials.json` operator-owned and excludes it from byte comparison; future policy changes take effect without a detector edit.
|
|
||||||
- A file present only in the deployed tools tree is operator-owned/unknown by the manifest's fail-safe default. The detector reports it as `INSTALLED_ONLY operator-or-unknown` under `--verbose` but does not fail or delete it.
|
|
||||||
- Empty/partial source traversal, unreadable directories/files, symlinked census entries, root aliases, and descendant source aliases all return `CANNOT_ASSERT` rather than manufacturing agreement.
|
|
||||||
|
|
||||||
This means `NOT_INSTALLED` is not suppressed by filename guesses such as “test” or “README”: if it ships below source `tools/**`, the installer contract says it should be installed. Source-only implementation files outside `tools/**` are outside this detector population by construction.
|
|
||||||
|
|
||||||
## Current host analysis (observation only; no refresh performed)
|
|
||||||
|
|
||||||
A direct source-vs-installed census showed broad drift, including identity and messaging behavior:
|
|
||||||
|
|
||||||
- Identity/provider operations: stale `git/detect-platform.sh`, `issue-comment.sh`, `issue-create.sh`, `issue-close.sh`, `issue-view.sh`, `pr-create.sh`, `pr-merge.sh`, `pr-review.sh`, `pr-metadata.sh`; missing `pr-edit.sh` and several identity/read-back regression tools.
|
|
||||||
- Messaging/session: stale `tmux/agent-send.sh`, `tmux/send-message.sh`, their regressions, and `fleet/start-agent-session.sh`.
|
|
||||||
- Gate enforcement: stale `git/ci-queue-wait.sh`; missing the queue tri-state/process-level suites and terminal-green verifier.
|
|
||||||
- Lease/QA behavior: stale lease-broker launch/mutation/receipt tools and QA hooks.
|
|
||||||
|
|
||||||
Counts vary with source head and installed local/operator files; the detector prints measured counts every run rather than baking this snapshot into policy.
|
|
||||||
|
|
||||||
## Reviewed refresh command — analyse only, do not run during active seats
|
|
||||||
|
|
||||||
Use the package/release updater's manifest-driven keep-mode sync during a quiet maintenance window:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
MOSAIC_SYNC_ONLY=1 \
|
|
||||||
MOSAIC_INSTALL_MODE=keep \
|
|
||||||
MOSAIC_HOME="$HOME/.config/mosaic" \
|
|
||||||
bash /path/to/reviewed/@mosaicstack/mosaic/framework/install.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
For the globally installed package, resolve the reviewed installer rather than guessing its path:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
PACKAGE_ROOT="$(dirname "$(node -p "require.resolve('@mosaicstack/mosaic/package.json')")")"
|
|
||||||
MOSAIC_SYNC_ONLY=1 MOSAIC_INSTALL_MODE=keep MOSAIC_HOME="$HOME/.config/mosaic" \
|
|
||||||
bash "$PACKAGE_ROOT/framework/install.sh"
|
|
||||||
```
|
|
||||||
|
|
||||||
Do not run this while agent seats are active: the stale set includes identity selection, provider mutation, messaging, queue/merge guards, lease enforcement, and session launch. Syncing those files in place can change behavior between a seat's preflight and mutation.
|
|
||||||
|
|
||||||
## Post-refresh verification
|
|
||||||
|
|
||||||
1. Run `mosaic doctor --fail-on-warn`; require `stale=0 not-installed=0` from the framework drift summary (other unrelated doctor warnings must also be adjudicated).
|
|
||||||
2. Re-run the constructed process-level queue probes against the **installed path**, not the source checkout. Use the source suite while overriding its subject path in a reviewed scratch copy, or reproduce these exact observations:
|
|
||||||
- pending provider payload: guard must print `state=pending`, print the pending context, wait, and exit non-zero/timeout — never return immediately with rc 0;
|
|
||||||
- malformed payload: guard must print `state=malformed` and exit non-zero;
|
|
||||||
- unsupported but valid status vocabulary: guard must print `state=unknown` and exit non-zero.
|
|
||||||
3. Run provider author read-back for one deliberately low-risk wrapper operation before resuming fleet mutation work; wrapper self-report is not identity evidence.
|
|
||||||
4. Relaunch seats only after the quiet-window verification, because existing processes retain loaded environment/context.
|
|
||||||
|
|
||||||
## Probe evidence
|
|
||||||
|
|
||||||
The detector regression constructs a stale installed tool plus a missing shipped tool and observes rc 1 with distinct `STALE` and `NOT_INSTALLED` lines. That case would pass or be invisible before this change because no installed-vs-shipped comparison existed. Additional review-red controls prove:
|
|
||||||
|
|
||||||
- empty and unreadable source censuses return `CANNOT_ASSERT` (they returned clean rc 0 at the first PR head);
|
|
||||||
- deleting the manifest returns `CANNOT_ASSERT`, while changing manifest ownership changes the verdict through the canonical resolver (the first head never opened the manifest);
|
|
||||||
- root and descendant symlink/source aliases cannot return clean (the first head returned clean for a source-backed installed subtree);
|
|
||||||
- a checker hung during doctor is terminated by a bounded watchdog, emits `CANNOT_ASSERT`, and doctor reaches its final warnings line (the first head hung and suppressed the remaining audit).
|
|
||||||
|
|
||||||
Controls retain byte-identical success, exact credential carve-out behavior, and installed-only preservation.
|
|
||||||
@@ -13,8 +13,7 @@ Pi is the native Mosaic agent runtime. The `mosaic pi` launcher:
|
|||||||
|
|
||||||
1. Injects the full runtime contract via `--append-system-prompt`
|
1. Injects the full runtime contract via `--append-system-prompt`
|
||||||
2. Loads Mosaic skills via `--skill` flags
|
2. Loads Mosaic skills via `--skill` flags
|
||||||
3. Loads framework-owned `mosaic-extension.ts` and `goal-extension.ts` from
|
3. Loads the Mosaic extension via `--extension` for lifecycle hooks
|
||||||
`~/.config/mosaic/runtime/pi/` via ordered `--extension` flags
|
|
||||||
4. Detects active missions and injects initial prompts
|
4. Detects active missions and injects initial prompts
|
||||||
|
|
||||||
## Capabilities vs Other Runtimes
|
## Capabilities vs Other Runtimes
|
||||||
@@ -23,7 +22,6 @@ Pi is the native Mosaic agent runtime. The `mosaic pi` launcher:
|
|||||||
- Native thinking levels replace sequential-thinking MCP
|
- Native thinking levels replace sequential-thinking MCP
|
||||||
- Native skill discovery compatible with Mosaic SKILL.md format
|
- Native skill discovery compatible with Mosaic SKILL.md format
|
||||||
- Native extension system for lifecycle hooks (TypeScript, not bash shims)
|
- Native extension system for lifecycle hooks (TypeScript, not bash shims)
|
||||||
- Bounded persistent `/goal` loop with per-turn, post-compaction, and two-pass evidence checks
|
|
||||||
- Native session persistence and resume
|
- Native session persistence and resume
|
||||||
- Model-agnostic (Anthropic, OpenAI, Google, Ollama, custom providers)
|
- Model-agnostic (Anthropic, OpenAI, Google, Ollama, custom providers)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
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;
|
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
|
scratchpad updated. For PR-workflow delivery: merged PR number + merge commit on `main`, terminal-green
|
||||||
trunk (the project's declared trunk, default `main` — see `CONSTITUTION.md` Hard Gates), terminal-green
|
|
||||||
CI, linked issue closed (or `docs/TASKS.md` equivalent). If blocked by access/tooling, return `blocked`
|
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`.
|
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
|
## 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.
|
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.
|
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.
|
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.
|
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.
|
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.
|
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.
|
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.
|
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.
|
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").
|
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.
|
16. If you modify source code, an independent review (author ≠ reviewer) must pass before completion.
|
||||||
|
|
||||||
## Integrity (quality gates are never bypassed)
|
## Integrity (quality gates are never bypassed)
|
||||||
|
|||||||
@@ -104,14 +104,7 @@ The launcher:
|
|||||||
1. Verifies `~/.config/mosaic` exists
|
1. Verifies `~/.config/mosaic` exists
|
||||||
2. Verifies `SOUL.md` exists (auto-runs `mosaic init` if missing)
|
2. Verifies `SOUL.md` exists (auto-runs `mosaic init` if missing)
|
||||||
3. Injects `AGENTS.md` into the runtime
|
3. Injects `AGENTS.md` into the runtime
|
||||||
4. For Pi, loads the framework-owned core and persistent-goal extensions from
|
4. Forwards all arguments to the runtime CLI
|
||||||
`~/.config/mosaic/runtime/pi/`
|
|
||||||
5. Forwards all arguments to the runtime CLI
|
|
||||||
|
|
||||||
Inside `mosaic pi`, `/goal set <statement>` starts a bounded persistent goal loop. Use `/goal status`,
|
|
||||||
`/goal pause`, `/goal resume`, or `/goal cancel` to control it. The extension remains part of Mosaic
|
|
||||||
under `~/.config/mosaic/runtime/pi/goal-extension.ts`; it is not installed in Pi's main extension
|
|
||||||
directory.
|
|
||||||
|
|
||||||
You can still launch runtimes directly (`claude`, `codex`, etc.) — thin runtime adapters will tell the agent to read `~/.config/mosaic/AGENTS.md`.
|
You can still launch runtimes directly (`claude`, `codex`, etc.) — thin runtime adapters will tell the agent to read `~/.config/mosaic/AGENTS.md`.
|
||||||
|
|
||||||
@@ -131,7 +124,7 @@ You can still launch runtimes directly (`claude`, `codex`, etc.) — thin runtim
|
|||||||
│ ├── claude/ ← CLAUDE.md, RUNTIME.md, settings.json, hooks
|
│ ├── claude/ ← CLAUDE.md, RUNTIME.md, settings.json, hooks
|
||||||
│ ├── codex/ ← instructions.md, RUNTIME.md
|
│ ├── codex/ ← instructions.md, RUNTIME.md
|
||||||
│ ├── opencode/ ← AGENTS.md, RUNTIME.md
|
│ ├── opencode/ ← AGENTS.md, RUNTIME.md
|
||||||
│ ├── pi/ ← RUNTIME.md, mosaic-extension.ts, goal-extension.ts
|
│ ├── pi/ ← RUNTIME.md, mosaic-extension.ts
|
||||||
│ └── mcp/ ← MCP server configs
|
│ └── mcp/ ← MCP server configs
|
||||||
├── skills/ ← Universal skills (shipped with the framework package)
|
├── skills/ ← Universal skills (shipped with the framework package)
|
||||||
├── skills-local/ ← Local cross-runtime skills
|
├── skills-local/ ← Local cross-runtime skills
|
||||||
@@ -143,7 +136,7 @@ You can still launch runtimes directly (`claude`, `codex`, etc.) — thin runtim
|
|||||||
|
|
||||||
| Launch method | Injection mechanism |
|
| Launch method | Injection mechanism |
|
||||||
| ------------------- | ----------------------------------------------------------------------------------------- |
|
| ------------------- | ----------------------------------------------------------------------------------------- |
|
||||||
| `mosaic pi` | `--append-system-prompt` with composed runtime contract + skills + Mosaic extensions |
|
| `mosaic pi` | `--append-system-prompt` with composed runtime contract + skills + extension |
|
||||||
| `mosaic claude` | `--append-system-prompt` with composed runtime contract (`AGENTS.md` + runtime reference) |
|
| `mosaic claude` | `--append-system-prompt` with composed runtime contract (`AGENTS.md` + runtime reference) |
|
||||||
| `mosaic codex` | Writes composed runtime contract to `~/.codex/instructions.md` before launch |
|
| `mosaic codex` | Writes composed runtime contract to `~/.codex/instructions.md` before launch |
|
||||||
| `mosaic opencode` | Writes composed runtime contract to `~/.config/opencode/AGENTS.md` before launch |
|
| `mosaic opencode` | Writes composed runtime contract to `~/.config/opencode/AGENTS.md` before launch |
|
||||||
|
|||||||
@@ -60,52 +60,6 @@ If a repo does not expose these scripts, run equivalent local workflow commands
|
|||||||
- Do not auto-resolve data conflicts in shared state files.
|
- Do not auto-resolve data conflicts in shared state files.
|
||||||
- Keep commits scoped to a single logical change set.
|
- Keep commits scoped to a single logical change set.
|
||||||
|
|
||||||
## Model Tiering
|
|
||||||
|
|
||||||
Model choice is a standard, not a preference. Delegating a mechanical grep to a
|
|
||||||
frontier reasoning model wastes budget; sending a security review to a cheap tier
|
|
||||||
produces a review that passes and proves nothing. Both are defects.
|
|
||||||
|
|
||||||
Tiers are named by **capability class**, so the standard survives a model
|
|
||||||
generation. An operator binds each class to a concrete model id.
|
|
||||||
|
|
||||||
| Class | Use for |
|
|
||||||
| ------------- | ----------------------------------------------------------------------------------------- |
|
|
||||||
| `search` | grep/glob, file location, status and health checks, one-line mechanical edits |
|
|
||||||
| `build` | feature implementation, test writing, bugfixes, routine refactors |
|
|
||||||
| `judge` | code review, planning, API/compat-sensitive changes |
|
|
||||||
| `adversarial` | security review, ambiguous architecture, anything where a wrong "looks fine" is expensive |
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
|
|
||||||
1. **Start at the cheapest class that can do the task; escalate on evidence, not
|
|
||||||
on nerves.** Omitting a tier is not neutral — it inherits the caller's model,
|
|
||||||
which is usually the most expensive one.
|
|
||||||
2. **Compat-sensitive work escalates one class.** A change that must interoperate
|
|
||||||
with an existing contract is judged, not just built.
|
|
||||||
3. **A tier assignment is benchmarked, not asserted.** Move a task class to a
|
|
||||||
cheaper tier only against a blind A/B on real work from this codebase, ranked
|
|
||||||
by someone other than the author. "It seemed fine" is not evidence.
|
|
||||||
4. **Reviewer independence beats reviewer size.** An `adversarial` verdict from
|
|
||||||
the model that wrote the code is not a second opinion (see Constitution gate 16).
|
|
||||||
|
|
||||||
### Where the binding lives
|
|
||||||
|
|
||||||
The class→model map is operator configuration, never framework source: model
|
|
||||||
availability, cost, and quotas differ per operator and per host.
|
|
||||||
|
|
||||||
Resolution order, first hit wins:
|
|
||||||
|
|
||||||
1. the config service (DB-backed, surfaced and editable in the Mosaic webUI)
|
|
||||||
2. a local operator file (`STANDARDS.local.md`, or `policy/` where the runtime
|
|
||||||
injects it)
|
|
||||||
3. the framework default — the class names above, with no binding
|
|
||||||
|
|
||||||
Only layer 1 is auditable across a fleet, so it is the target end state; layers 2
|
|
||||||
and 3 exist so a host with no config service still runs. A local override that
|
|
||||||
silently disagrees with the config service is drift — the same failure class the
|
|
||||||
tool-index gate exists to catch, and it belongs in `mosaic doctor`.
|
|
||||||
|
|
||||||
## Prompting Contract
|
## Prompting Contract
|
||||||
|
|
||||||
All runtime adapters should inject:
|
All runtime adapters should inject:
|
||||||
|
|||||||
@@ -35,4 +35,4 @@ A standing panel of senior voices, each arguing from a fixed vantage. The board
|
|||||||
deliberately slow and adversarial — its value is catching the expensive mistake
|
deliberately slow and adversarial — its value is catching the expensive mistake
|
||||||
before a single agent-hour is spent on it.
|
before a single agent-hour is spent on it.
|
||||||
|
|
||||||
> Doctrine: `docs/fleet/FLEET-DOCTRINE.md` ('board' role = forge BOD; role library).
|
> Doctrine: `docs/fleet/north-star.md` ('board' role = forge BOD; role library).
|
||||||
|
|||||||
@@ -33,4 +33,4 @@ The code role writes the change and opens the PR; it never touches the merge pat
|
|||||||
The focused builder. It takes one well-scoped card, drives it to green, opens a
|
The focused builder. It takes one well-scoped card, drives it to green, opens a
|
||||||
clean PR, and hands off — never reaching past the card it was given.
|
clean PR, and hands off — never reaching past the card it was given.
|
||||||
|
|
||||||
> Doctrine: `docs/fleet/FLEET-DOCTRINE.md` (role library).
|
> Doctrine: `docs/fleet/north-star.md` (role library).
|
||||||
|
|||||||
@@ -35,4 +35,4 @@ The work-breakdown specialist. It takes a phased plan and a DAG and emits a clea
|
|||||||
linked set of single-PR cards on the Mosaic backlog — then steps back and lets the
|
linked set of single-PR cards on the Mosaic backlog — then steps back and lets the
|
||||||
executors run.
|
executors run.
|
||||||
|
|
||||||
> Doctrine: `docs/fleet/FLEET-DOCTRINE.md` (role library); spend accounting is a process mandate.
|
> Doctrine: `docs/fleet/north-star.md` (role library); spend accounting is a process mandate.
|
||||||
|
|||||||
@@ -36,4 +36,4 @@ The scribe of record. It makes sure the docs and the north star's projections
|
|||||||
describe the system as it actually is, and it never lets two writers fight over one
|
describe the system as it actually is, and it never lets two writers fight over one
|
||||||
TASKS file.
|
TASKS file.
|
||||||
|
|
||||||
> Doctrine: `docs/fleet/FLEET-DOCTRINE.md` (role library).
|
> Doctrine: `docs/fleet/north-star.md` (role library).
|
||||||
|
|||||||
@@ -38,4 +38,4 @@ The orchestrator alone optimizes for _this_ delivery; the enhancer optimizes for
|
|||||||
delivery — self-healing the fleet's tools, skills, and harnesses, and routing real defects
|
delivery — self-healing the fleet's tools, skills, and harnesses, and routing real defects
|
||||||
upstream. Together they are the irreducible core; every other role is added on demand.
|
upstream. Together they are the irreducible core; every other role is added on demand.
|
||||||
|
|
||||||
> Doctrine: `docs/fleet/FLEET-DOCTRINE.md` (two-agent floor + role library).
|
> Doctrine: `docs/fleet/north-star.md` (two-agent floor + role library).
|
||||||
|
|||||||
@@ -46,4 +46,4 @@ The single, accountable gatekeeper. It waits for green CI (`pr-ci-wait.sh`),
|
|||||||
respects the pause switch, merges only through `pr-merge.sh`, and records every
|
respects the pause switch, merges only through `pr-merge.sh`, and records every
|
||||||
decision — so the fleet has exactly one trustworthy door to production.
|
decision — so the fleet has exactly one trustworthy door to production.
|
||||||
|
|
||||||
> Doctrine: `docs/fleet/FLEET-DOCTRINE.md` (role library); merge path: `pr-merge.sh` + `pr-ci-wait.sh`; forbidden paths: `pr-merge.sh` guard.
|
> Doctrine: `docs/fleet/north-star.md` (role library); merge path: `pr-merge.sh` + `pr-ci-wait.sh`; forbidden paths: `pr-merge.sh` guard.
|
||||||
|
|||||||
@@ -35,4 +35,4 @@ The on-call dispatcher. It makes sure every escalation is seen and re-seen until
|
|||||||
handled, and it holds the one switch that can stop the fleet when something is
|
handled, and it holds the one switch that can stop the fleet when something is
|
||||||
wrong.
|
wrong.
|
||||||
|
|
||||||
> Doctrine: `docs/fleet/FLEET-DOCTRINE.md` (role library); pause switch: `fleet/run/PAUSED`.
|
> Doctrine: `docs/fleet/north-star.md` (role library); pause switch: `fleet/run/PAUSED`.
|
||||||
|
|||||||
@@ -43,4 +43,4 @@ A lean, decisive coordinator. It thinks in readiness and throughput, dispatches
|
|||||||
next ready card the instant a dependency clears, and never lets an idle agent sit
|
next ready card the instant a dependency clears, and never lets an idle agent sit
|
||||||
while ready work exists — keeping its own context minimal so the loop never slows.
|
while ready work exists — keeping its own context minimal so the loop never slows.
|
||||||
|
|
||||||
> Doctrine: `docs/fleet/FLEET-DOCTRINE.md` (two-agent floor + role library).
|
> Doctrine: `docs/fleet/north-star.md` (two-agent floor + role library).
|
||||||
|
|||||||
@@ -38,4 +38,4 @@ The architect of the mission's shape. It thinks in phases and dependencies, hand
|
|||||||
a clean DAG to decomposition, and reports its plan back to the orchestrator that
|
a clean DAG to decomposition, and reports its plan back to the orchestrator that
|
||||||
dispatched it.
|
dispatched it.
|
||||||
|
|
||||||
> Doctrine: `docs/fleet/FLEET-DOCTRINE.md` (two-agent floor + role library).
|
> Doctrine: `docs/fleet/north-star.md` (two-agent floor + role library).
|
||||||
|
|||||||
@@ -34,4 +34,4 @@ The rebase role keeps PR branches fresh; it never approves or merges.
|
|||||||
The janitor of the merge queue. It quietly keeps branches current and re-runnable,
|
The janitor of the merge queue. It quietly keeps branches current and re-runnable,
|
||||||
and knows when a conflict is beyond a mechanical rebase and must be escalated.
|
and knows when a conflict is beyond a mechanical rebase and must be escalated.
|
||||||
|
|
||||||
> Doctrine: `docs/fleet/FLEET-DOCTRINE.md` (role library).
|
> Doctrine: `docs/fleet/north-star.md` (role library).
|
||||||
|
|||||||
@@ -35,4 +35,4 @@ The careful reader. It assumes nothing, checks the change against its card and i
|
|||||||
tests, and is willing to say "not yet" — its value is catching the wrong change
|
tests, and is willing to say "not yet" — its value is catching the wrong change
|
||||||
before it reaches the merge-gate.
|
before it reaches the merge-gate.
|
||||||
|
|
||||||
> Doctrine: `docs/fleet/FLEET-DOCTRINE.md` (role library).
|
> Doctrine: `docs/fleet/north-star.md` (role library).
|
||||||
|
|||||||
@@ -36,4 +36,4 @@ tree or the merge path.
|
|||||||
The adversary on your side. It reads every diff asking "how does this get exploited
|
The adversary on your side. It reads every diff asking "how does this get exploited
|
||||||
or leak?" — the second, security-focused pair of eyes before the merge-gate.
|
or leak?" — the second, security-focused pair of eyes before the merge-gate.
|
||||||
|
|
||||||
> Doctrine: `docs/fleet/FLEET-DOCTRINE.md` (role library); forbidden paths: `pr-merge.sh` guard.
|
> Doctrine: `docs/fleet/north-star.md` (role library); forbidden paths: `pr-merge.sh` guard.
|
||||||
|
|||||||
@@ -34,4 +34,4 @@ The retrospective analyst. It reads completed sessions and distills them into cl
|
|||||||
actionable signals — the raw material the enhancer uses to make the fleet better
|
actionable signals — the raw material the enhancer uses to make the fleet better
|
||||||
next time.
|
next time.
|
||||||
|
|
||||||
> Doctrine: `docs/fleet/FLEET-DOCTRINE.md` (role library); consumed by the enhancer role.
|
> Doctrine: `docs/fleet/north-star.md` (role library); consumed by the enhancer role.
|
||||||
|
|||||||
@@ -34,4 +34,4 @@ merge path.
|
|||||||
The skeptic who insists on running it. It trusts observed behavior over claimed
|
The skeptic who insists on running it. It trusts observed behavior over claimed
|
||||||
behavior, and turns "should work" into "verified works" — or a concrete bug report.
|
behavior, and turns "should work" into "verified works" — or a concrete bug report.
|
||||||
|
|
||||||
> Doctrine: `docs/fleet/FLEET-DOCTRINE.md` (role library).
|
> Doctrine: `docs/fleet/north-star.md` (role library).
|
||||||
|
|||||||
@@ -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
|
4. Issue tracking is consistent across projects
|
||||||
5. Documentation standards and API contracts are enforced from day one
|
5. Documentation standards and API contracts are enforced from day one
|
||||||
6. PRD requirements are established before coding begins
|
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
|
8. Steered-autonomy execution is enabled so agents can run end-to-end with escalation-only human intervention
|
||||||
|
|
||||||
## Agent Host Prerequisites
|
## Agent Host Prerequisites
|
||||||
@@ -206,7 +206,7 @@ Every runtime context file should contain:
|
|||||||
6. **Issue tracking** — Issue and commit conventions
|
6. **Issue tracking** — Issue and commit conventions
|
||||||
7. **Code review** — Required review process
|
7. **Code review** — Required review process
|
||||||
8. **Runtime notes** — Runtime-specific behavior references
|
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
|
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
|
Apply equivalent settings in Gitea, GitHub, or GitLab:
|
||||||
(the branch its `.mosaic/repo.json` declares under `integration_trunk`; default `main` — see
|
|
||||||
`CONSTITUTION.md` Hard Gates):
|
|
||||||
|
|
||||||
1. Protect the integration trunk from direct pushes.
|
1. Protect `main` from direct pushes.
|
||||||
2. Require pull requests to merge into the integration trunk.
|
2. Require pull requests to merge into `main`.
|
||||||
3. Require required CI/status checks to pass before merge.
|
3. Require required CI/status checks to pass before merge.
|
||||||
4. Require code review approval 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.
|
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.)
|
- [ ] Git labels created (epic, feature, bug, task, etc.)
|
||||||
- [ ] Initial pre-MVP milestone created (0.0.1)
|
- [ ] Initial pre-MVP milestone created (0.0.1)
|
||||||
- [ ] MVP milestone reserved for release (0.1.0)
|
- [ ] MVP milestone reserved for release (0.1.0)
|
||||||
- [ ] The integration trunk is protected from direct pushes
|
- [ ] `main` is protected from direct pushes
|
||||||
- [ ] PRs into the integration trunk are required
|
- [ ] PRs into `main` are required
|
||||||
- [ ] Merge method for the integration trunk is squash-only
|
- [ ] Merge method for `main` is squash-only
|
||||||
- [ ] Quality gates run successfully
|
- [ ] Quality gates run successfully
|
||||||
- [ ] `.env.example` exists (if project uses env vars)
|
- [ ] `.env.example` exists (if project uses env vars)
|
||||||
- [ ] CI/CD pipeline configured (if using Woodpecker/GitHub Actions)
|
- [ ] CI/CD pipeline configured (if using Woodpecker/GitHub Actions)
|
||||||
|
|||||||
@@ -4,11 +4,6 @@
|
|||||||
|
|
||||||
## Overview
|
## 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:
|
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
|
```yaml
|
||||||
image: git.example.com/org/service@${IMAGE_DIGEST}
|
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
|
8. **Verify images appear** in Gitea Packages tab after successful pipeline
|
||||||
|
|
||||||
## Terminal-Green Full-Step Contract
|
## Terminal-Green Full-Step Contract
|
||||||
@@ -911,7 +906,7 @@ For source-code delivery, completion is not allowed at "PR opened" stage.
|
|||||||
|
|
||||||
Required sequence:
|
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:
|
2. Monitor CI to terminal status:
|
||||||
```bash
|
```bash
|
||||||
~/.config/mosaic/tools/git/pr-ci-wait.sh -n <PR_NUMBER>
|
~/.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
|
### 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
|
- 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):
|
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 `main`.
|
||||||
- PR target for delivery is the integration trunk.
|
- Direct pushes to `main` are prohibited.
|
||||||
- Direct pushes to the integration trunk are prohibited.
|
- Merge to `main` MUST be squash-only.
|
||||||
- Merge to the integration trunk 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).
|
- 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
|
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`
|
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.
|
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
|
### Feedback Categories
|
||||||
|
|
||||||
- **Blocker**: must fix before merge (security, bugs, test failures)
|
- **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
|
# List the issue being addressed
|
||||||
~/.config/mosaic/tools/git/issue-list.sh -i {issue-number}
|
~/.config/mosaic/tools/git/issue-list.sh -i {issue-number}
|
||||||
|
|
||||||
# View the changes (diff against the integration trunk; default: main)
|
# View the changes
|
||||||
git diff {integration_trunk}...HEAD
|
git diff main...HEAD
|
||||||
```
|
```
|
||||||
|
|
||||||
### Providing Feedback
|
### 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
|
2. If changes requested, assign back to author
|
||||||
3. If approved, note approval in issue comments
|
3. If approved, note approval in issue comments
|
||||||
4. For merges, ensure CI passes first
|
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.
|
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`.
|
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.
|
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>`.
|
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).
|
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).
|
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.
|
10. No unresolved blocker hidden.
|
||||||
11. If deployment is in scope, deployment target, release version, and post-deploy verification evidence are documented.
|
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.
|
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.
|
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).
|
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.
|
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`
|
- [ ] All milestone tasks in TASKS.md are `done`
|
||||||
- [ ] CI/pipeline green
|
- [ ] CI/pipeline green
|
||||||
- [ ] PR merged to the integration trunk
|
- [ ] PR merged to `main`
|
||||||
- [ ] Issues closed
|
- [ ] Issues closed
|
||||||
- [ ] Update manifest: milestone status → completed
|
- [ ] Update manifest: milestone status → completed
|
||||||
- [ ] Update scratchpad: session log entry
|
- [ ] 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 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 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 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 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).
|
- 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.
|
- 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):
|
Branch and merge strategy (HARD RULE):
|
||||||
|
|
||||||
- Workers use short-lived task branches from `origin/{integration_trunk}` (default `main`).
|
- Workers use short-lived task branches from `origin/main`.
|
||||||
- Worker task branches merge back via PR to the integration trunk only.
|
- Worker task branches merge back via PR to `main` only.
|
||||||
- Direct pushes to the integration trunk are prohibited.
|
- Direct pushes to `main` are prohibited.
|
||||||
- PR merges to the integration trunk MUST use squash merge.
|
- PR merges to `main` MUST use squash merge.
|
||||||
|
|
||||||
**Available templates:**
|
**Available templates:**
|
||||||
|
|
||||||
@@ -427,7 +427,7 @@ git push
|
|||||||
- Before merging, run queue guard:
|
- 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>`
|
`~/.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):
|
- 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:
|
- Merge via wrapper:
|
||||||
`~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash --expect-head {approved_full_sha}`
|
`~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash --expect-head {approved_full_sha}`
|
||||||
- Wait for terminal CI status:
|
- Wait for terminal CI status:
|
||||||
@@ -619,7 +619,7 @@ Construct this from the task row and pass to worker via Task tool:
|
|||||||
|
|
||||||
## Workflow
|
## 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
|
2. Read `docs/PRD.md` or `docs/PRD.json` and align implementation with PRD requirements
|
||||||
3. Read the finding details from the report
|
3. Read the finding details from the report
|
||||||
4. Implement the fix following existing code patterns
|
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):
|
For issue/PR/milestone operations, use scripts (NOT raw tea/gh):
|
||||||
|
|
||||||
- `~/.config/mosaic/tools/git/issue-view.sh -i {N}`
|
- `~/.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}`
|
- 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}`
|
- 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}`
|
- `~/.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:
|
1. Build and push immutable candidate image tags:
|
||||||
- `sha-<shortsha>` (always)
|
- `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
|
- `testing` mutable pointer to the same digest
|
||||||
2. Resolve and record the image digest for each service.
|
2. Resolve and record the image digest for each service.
|
||||||
3. Deploy by digest to testing environment (never deploy by mutable tag alone).
|
3. Deploy by digest to testing environment (never deploy by mutable tag alone).
|
||||||
|
|||||||
@@ -11,106 +11,22 @@ All tool suites are located at `~/.config/mosaic/tools/`.
|
|||||||
|
|
||||||
Mosaic wrappers at `~/.config/mosaic/tools/git/*.sh` handle platform detection and edge cases. Always use these before raw CLI commands.
|
Mosaic wrappers at `~/.config/mosaic/tools/git/*.sh` handle platform detection and edge cases. Always use these before raw CLI commands.
|
||||||
|
|
||||||
This index is complete and is kept complete mechanically: `tools/quality/scripts/check-tools-index.sh`
|
|
||||||
fails CI when a wrapper ships without an entry here, or when an entry here names a wrapper that no
|
|
||||||
longer exists. A wrapper missing from this list is, from inside an agent session, indistinguishable
|
|
||||||
from a wrapper that was never written — which is how the APPROVE/APPROVED incident below happened.
|
|
||||||
|
|
||||||
Every command takes `--help`. All of them accept `--login <account>` to pin the acting identity;
|
|
||||||
supply it explicitly on any host where the provider CLI's default account is an admin.
|
|
||||||
|
|
||||||
| Issues | |
|
|
||||||
| ------------------ | --------------------------------- |
|
|
||||||
| `issue-create.sh` | Create an issue (Gitea or GitHub) |
|
|
||||||
| `issue-view.sh` | Show one issue |
|
|
||||||
| `issue-list.sh` | List issues |
|
|
||||||
| `issue-edit.sh` | Edit title/body/labels/milestone |
|
|
||||||
| `issue-comment.sh` | Add a comment |
|
|
||||||
| `issue-assign.sh` | Assign or unassign |
|
|
||||||
| `issue-close.sh` | Close an issue |
|
|
||||||
| `issue-reopen.sh` | Reopen a closed issue |
|
|
||||||
|
|
||||||
| Pull requests | |
|
|
||||||
| ---------------- | --------------------------------------------------------- |
|
|
||||||
| `pr-create.sh` | Open a pull request |
|
|
||||||
| `pr-edit.sh` | Edit PR title, body, base branch, or draft/ready state |
|
|
||||||
| `pr-view.sh` | Show one PR |
|
|
||||||
| `pr-list.sh` | List PRs |
|
|
||||||
| `pr-diff.sh` | Fetch a PR's diff |
|
|
||||||
| `pr-metadata.sh` | PR metadata as JSON (head SHA, base, state, mergeability) |
|
|
||||||
| `pr-review.sh` | **Place a review verdict — see the dialect note below** |
|
|
||||||
| `pr-ci-wait.sh` | Block until the PR's CI reaches a terminal state |
|
|
||||||
| `pr-merge.sh` | Merge a PR |
|
|
||||||
| `pr-close.sh` | Close a PR without merging |
|
|
||||||
|
|
||||||
| Milestones | |
|
|
||||||
| --------------------- | ------------------ |
|
|
||||||
| `milestone-create.sh` | Create a milestone |
|
|
||||||
| `milestone-list.sh` | List milestones |
|
|
||||||
| `milestone-close.sh` | Close a milestone |
|
|
||||||
|
|
||||||
| Gates and guards | |
|
|
||||||
| ----------------------- | --------------------------------------------------------------------------------------------------------- |
|
|
||||||
| `ci-queue-wait.sh` | CI queue guard — required before push/merge (see below) |
|
|
||||||
| `push-guard.sh` | Refuse verifications that pass for the wrong reason (e.g. green against an unpushed tree) |
|
|
||||||
| `mutate-push-guard.sh` | Regenerate the guard's mutation-coverage table from measurement, so the table cannot drift from the guard |
|
|
||||||
| `verify-clean-clone.sh` | Prove the **committed** artifact runs, from a clean clone — not the working tree |
|
|
||||||
|
|
||||||
| Context | |
|
|
||||||
| -------------------- | ---------------------------------------------------------------------------------------- |
|
|
||||||
| `detect-platform.sh` | Resolve the provider (Gitea vs GitHub) for the current repo; every other wrapper uses it |
|
|
||||||
| `lane-brief.sh` | Live dispatch brief for a repo "lane" (milestone/label) straight from the provider |
|
|
||||||
|
|
||||||
| Workspace | |
|
|
||||||
| -------------------- | ------------------------------------------------------------------------ |
|
|
||||||
| `mosaic-worktree.sh` | Create/list/remove git worktrees — **the only supported way**; see below |
|
|
||||||
| `wrapper-guard.sh` | PreToolUse hook that enforces the two rules above; not called by hand |
|
|
||||||
|
|
||||||
**Workspace placement is derived, not chosen.** `mosaic-worktree.sh new <branch>` takes a branch
|
|
||||||
name and nothing else. Every path comes out of `git worktree list --porcelain` — main worktree,
|
|
||||||
repo name, parent dir, then `<parent>/<repo>-worktrees/<branch-slug>`. There is no placement flag
|
|
||||||
because a decision an agent has to make is a decision that drifts: the rule "big work goes on a work
|
|
||||||
filesystem" already existed in prose and 255 GB accumulated in `$HOME` across 842 directories
|
|
||||||
anyway, under five simultaneous conventions on a single host.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
~/.config/mosaic/tools/git/mosaic-worktree.sh new <branch> [--from <base>]
|
# Issues
|
||||||
~/.config/mosaic/tools/git/mosaic-worktree.sh path <branch> # derived path, no side effect
|
~/.config/mosaic/tools/git/issue-create.sh
|
||||||
~/.config/mosaic/tools/git/mosaic-worktree.sh list # this repo's worktrees + state
|
~/.config/mosaic/tools/git/issue-close.sh
|
||||||
~/.config/mosaic/tools/git/mosaic-worktree.sh rm <branch> # removal is part of the task
|
|
||||||
~/.config/mosaic/tools/git/mosaic-worktree.sh gc [--apply] # reclaim clean + fully-pushed ones
|
|
||||||
```
|
|
||||||
|
|
||||||
Worktrees rather than clones, because `git worktree list` makes every checkout enumerable — a bare
|
# PRs
|
||||||
clone dropped somewhere on disk can never be safely reclaimed, so it is never reclaimed. `rm` and
|
~/.config/mosaic/tools/git/pr-create.sh
|
||||||
`gc` decide by **evidence, never by size or age**: a worktree is reclaimable only when
|
~/.config/mosaic/tools/git/pr-merge.sh
|
||||||
`git status --porcelain` is empty _and_ `git rev-list --count HEAD --not --remotes` is 0. Anything
|
|
||||||
else is preserved and reported. `--force` exists and is yours to type deliberately.
|
|
||||||
|
|
||||||
`wrapper-guard.sh` is registered as a Claude Code `PreToolUse` hook on `Bash` (see
|
# Milestones
|
||||||
`runtime/claude/settings.json`). It blocks exactly three things and lets everything else through:
|
~/.config/mosaic/tools/git/milestone-create.sh
|
||||||
a `git clone`/`git worktree add` targeting `$HOME`; a raw provider-API **write** to an endpoint that
|
|
||||||
already has a wrapper above (reads are untouched — they are how you gather evidence); and the
|
|
||||||
literal `"event": "APPROVE"`. For a genuine gap no wrapper can express, prefix
|
|
||||||
`MOSAIC_WRAPPER_OVERRIDE=1`. Reaching for the override twice for the same call means the wrapper has
|
|
||||||
a missing flag — extend the wrapper.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
~/.config/mosaic/tools/git/issue-create.sh --help
|
|
||||||
~/.config/mosaic/tools/git/pr-review.sh --pr 42 --event APPROVED --body "..."
|
|
||||||
|
|
||||||
# CI queue guard (required before push/merge; defaults to the checked-out branch)
|
# CI queue guard (required before push/merge; defaults to the checked-out branch)
|
||||||
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge
|
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge
|
||||||
```
|
```
|
||||||
|
|
||||||
**Review dialect — the reason `pr-review.sh` is not optional.** Gitea's approve event is
|
|
||||||
`APPROVED`; GitHub's is `APPROVE`. Send GitHub's spelling to a Gitea host and it answers **HTTP
|
|
||||||
200**, files the review as PENDING, and then rejects the submit with `422 review stay pending` — the
|
|
||||||
verdict looks placed and is not. (`REQUEST_CHANGES` is spelled identically on both, so only the
|
|
||||||
approve path carries the trap.) `pr-review.sh` sends the correct token for the detected provider.
|
|
||||||
Whatever you use, re-read `GET /pulls/{n}/reviews` and assert the state before reporting a verdict
|
|
||||||
placed.
|
|
||||||
|
|
||||||
The guard exits nonzero for any provider-asserted non-green, missing, or malformed CI state. If credentials or the provider are unavailable, it emits `CANNOT_ASSERT` and writes a JSONL audit record. Push degrades to exit 0 so recovery work is not bricked; merge holds with retryable exit 75 until the provider recovers, then self-clears without manual reset. Neither outcome is evidence that CI was clear. `pr-merge.sh` automatically inspects the exact PR head repository and full commit SHA rather than its `main` base; this also handles fork PRs without branch-name ambiguity. Pass `--expect-head <approved-full-sha>` to bind a commit-specific review or merge-gate verdict; Gitea uses atomic `head_commit_id` and GitHub uses `--match-head-commit`.
|
The guard exits nonzero for any provider-asserted non-green, missing, or malformed CI state. If credentials or the provider are unavailable, it emits `CANNOT_ASSERT` and writes a JSONL audit record. Push degrades to exit 0 so recovery work is not bricked; merge holds with retryable exit 75 until the provider recovers, then self-clears without manual reset. Neither outcome is evidence that CI was clear. `pr-merge.sh` automatically inspects the exact PR head repository and full commit SHA rather than its `main` base; this also handles fork PRs without branch-name ambiguity. Pass `--expect-head <approved-full-sha>` to bind a commit-specific review or merge-gate verdict; Gitea uses atomic `head_commit_id` and GitHub uses `--match-head-commit`.
|
||||||
|
|
||||||
### Code Review (Codex)
|
### Code Review (Codex)
|
||||||
|
|||||||
@@ -64,16 +64,6 @@
|
|||||||
"timeout": 10
|
"timeout": 10
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
|
||||||
{
|
|
||||||
"matcher": "Bash",
|
|
||||||
"hooks": [
|
|
||||||
{
|
|
||||||
"type": "command",
|
|
||||||
"command": "~/.config/mosaic/tools/git/wrapper-guard.sh",
|
|
||||||
"timeout": 10
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"PostToolUse": [
|
"PostToolUse": [
|
||||||
|
|||||||
@@ -51,26 +51,12 @@ Skills are discovered from:
|
|||||||
|
|
||||||
### Extensions
|
### Extensions
|
||||||
|
|
||||||
`mosaic pi` loads framework-owned extensions directly from `~/.config/mosaic/runtime/pi/` in this
|
The Mosaic Pi extension (`~/.config/mosaic/runtime/pi/mosaic-extension.ts`) handles:
|
||||||
order:
|
|
||||||
|
|
||||||
1. `mosaic-extension.ts` — session lifecycle, mission context, memory routing, lease/mutator gates,
|
- Session start/end lifecycle hooks
|
||||||
and fleet heartbeat reporting.
|
- Active mission detection and context injection
|
||||||
2. `goal-extension.ts` — optional persistent `/goal` controller with per-turn and post-compaction
|
- Memory routing to `~/.config/mosaic/memory/`
|
||||||
checks.
|
- MACP queue status reporting
|
||||||
|
|
||||||
The goal extension is deployed by Mosaic and MUST NOT be copied into `~/.pi/agent/extensions/`.
|
|
||||||
Use `/goal set <statement>` (or `/goal <statement>`) to start, then `/goal status`, `/goal pause`,
|
|
||||||
`/goal resume`, or `/goal cancel` to control it. An active goal is injected before every model
|
|
||||||
request, restored from branch-specific session entries, and considered achieved only after two
|
|
||||||
consecutive evidence-bearing reports. Common credential shapes are redacted before controller-owned
|
|
||||||
goal-state entries are persisted or
|
|
||||||
displayed; Pi's own model/tool-call history is separate. Goals and reports must contain references
|
|
||||||
and pass/fail summaries rather than secrets or raw sensitive output.
|
|
||||||
|
|
||||||
- `MOSAIC_GOAL_MAX_TURNS` — autonomous turn limit, default `40`, accepted range `1..500`.
|
|
||||||
- `MOSAIC_GOAL_MAX_NO_PROGRESS` — identical no-progress report limit, default `6`, accepted range
|
|
||||||
`1..100`.
|
|
||||||
|
|
||||||
### Sessions
|
### Sessions
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -66,10 +66,7 @@ if command -v tmux >/dev/null 2>&1 && command -v cc >/dev/null 2>&1; then
|
|||||||
trap 'tmux -L "$TEST_SOCKET" kill-server >/dev/null 2>&1 || true; rm -rf "$TEST_ROOT"' EXIT
|
trap 'tmux -L "$TEST_SOCKET" kill-server >/dev/null 2>&1 || true; rm -rf "$TEST_ROOT"' EXIT
|
||||||
MARKER="$TEST_ROOT/loader-marker"
|
MARKER="$TEST_ROOT/loader-marker"
|
||||||
LIBRARY="$TEST_ROOT/marker.so"
|
LIBRARY="$TEST_ROOT/marker.so"
|
||||||
FIXTURE_READY="$TEST_ROOT/loader-ready"
|
|
||||||
FIXTURE_FIFO="$TEST_ROOT/loader-block"
|
|
||||||
HOLDER_HOME="$TEST_ROOT/holder-home"
|
HOLDER_HOME="$TEST_ROOT/holder-home"
|
||||||
mkfifo "$FIXTURE_FIFO"
|
|
||||||
mkdir -p "$HOLDER_HOME/.config/mosaic/fleet/run"
|
mkdir -p "$HOLDER_HOME/.config/mosaic/fleet/run"
|
||||||
chmod 700 "$HOLDER_HOME/.config" "$HOLDER_HOME/.config/mosaic" \
|
chmod 700 "$HOLDER_HOME/.config" "$HOLDER_HOME/.config/mosaic" \
|
||||||
"$HOLDER_HOME/.config/mosaic/fleet" "$HOLDER_HOME/.config/mosaic/fleet/run"
|
"$HOLDER_HOME/.config/mosaic/fleet" "$HOLDER_HOME/.config/mosaic/fleet/run"
|
||||||
@@ -90,17 +87,7 @@ __attribute__((constructor)) static void mark_loader(void) {
|
|||||||
EOF
|
EOF
|
||||||
cc -shared -fPIC -o "$LIBRARY" "$TEST_ROOT/marker.c"
|
cc -shared -fPIC -o "$LIBRARY" "$TEST_ROOT/marker.c"
|
||||||
MOSAIC_LOADER_MARKER="$MARKER" LD_PRELOAD="$LIBRARY" \
|
MOSAIC_LOADER_MARKER="$MARKER" LD_PRELOAD="$LIBRARY" \
|
||||||
tmux -L "$TEST_SOCKET" new-session -d -s _holder \
|
tmux -L "$TEST_SOCKET" new-session -d -s _holder 'sleep 60'
|
||||||
"touch '$FIXTURE_READY'; read _ < '$FIXTURE_FIFO'"
|
|
||||||
# tmux starts the pane asynchronously. Wait until its contaminated shell has
|
|
||||||
# loaded the constructor and reached a builtin-only FIFO barrier before
|
|
||||||
# clearing the marker; otherwise that expected constructor can race with the
|
|
||||||
# clean holder assertion below and create a false failure.
|
|
||||||
for _attempt in {1..100}; do
|
|
||||||
[ -e "$FIXTURE_READY" ] && break
|
|
||||||
sleep 0.01
|
|
||||||
done
|
|
||||||
[ -e "$FIXTURE_READY" ] || fail "contaminated fixture pane did not become ready"
|
|
||||||
[ -s "$MARKER" ] || fail "contaminated fixture did not execute loader constructor"
|
[ -s "$MARKER" ] || fail "contaminated fixture did not execute loader constructor"
|
||||||
server_pid=$(tmux -L "$TEST_SOCKET" display-message -p '#{pid}')
|
server_pid=$(tmux -L "$TEST_SOCKET" display-message -p '#{pid}')
|
||||||
: > "$MARKER"
|
: > "$MARKER"
|
||||||
@@ -125,7 +112,6 @@ EOF
|
|||||||
chmod 700 "$AGENT_HOME/fleet/agents"
|
chmod 700 "$AGENT_HOME/fleet/agents"
|
||||||
cat > "$AGENT_HOME/fleet/agents/$AGENT_NAME.env.generated" <<EOF
|
cat > "$AGENT_HOME/fleet/agents/$AGENT_NAME.env.generated" <<EOF
|
||||||
MOSAIC_AGENT_NAME=$AGENT_NAME
|
MOSAIC_AGENT_NAME=$AGENT_NAME
|
||||||
MOSAIC_GIT_IDENTITY=$AGENT_NAME
|
|
||||||
MOSAIC_AGENT_CLASS=code
|
MOSAIC_AGENT_CLASS=code
|
||||||
MOSAIC_AGENT_RUNTIME=pi
|
MOSAIC_AGENT_RUNTIME=pi
|
||||||
MOSAIC_AGENT_MODEL=
|
MOSAIC_AGENT_MODEL=
|
||||||
@@ -166,8 +152,7 @@ EOF
|
|||||||
/usr/bin/env -i HOME="$HOLDER_HOME" PATH=/usr/bin:/bin \
|
/usr/bin/env -i HOME="$HOLDER_HOME" PATH=/usr/bin:/bin \
|
||||||
MOSAIC_TMUX_SOCKET="$TEST_SOCKET" MOSAIC_TMUX_HOLDER=_holder "$HOLDER_START"
|
MOSAIC_TMUX_SOCKET="$TEST_SOCKET" MOSAIC_TMUX_HOLDER=_holder "$HOLDER_START"
|
||||||
tmux -L "$TEST_SOCKET" has-session -t '=_holder:0.0' || fail "fresh holder was not created"
|
tmux -L "$TEST_SOCKET" has-session -t '=_holder:0.0' || fail "fresh holder was not created"
|
||||||
ld_preload_env="$(tmux -L "$TEST_SOCKET" show-environment -g LD_PRELOAD 2>/dev/null)" || true
|
if tmux -L "$TEST_SOCKET" show-environment -g LD_PRELOAD 2>/dev/null | grep -q '^LD_PRELOAD='; then
|
||||||
if grep -q '^LD_PRELOAD=' <<<"$ld_preload_env"; then
|
|
||||||
fail "fresh holder retained LD_PRELOAD"
|
fail "fresh holder retained LD_PRELOAD"
|
||||||
fi
|
fi
|
||||||
/usr/bin/env -i HOME="$HOLDER_HOME" PATH=/usr/bin:/bin MOSAIC_HOME="$AGENT_HOME" \
|
/usr/bin/env -i HOME="$HOLDER_HOME" PATH=/usr/bin:/bin MOSAIC_HOME="$AGENT_HOME" \
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ _manifest_glob_to_ere() {
|
|||||||
out="$out.*"
|
out="$out.*"
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
out="${out}[^/]*"
|
out="$out[^/]*"
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
case "$c" in
|
case "$c" in
|
||||||
@@ -87,8 +87,7 @@ _manifest_compile_one() {
|
|||||||
local norm; norm="$(_manifest_norm "$1")"
|
local norm; norm="$(_manifest_norm "$1")"
|
||||||
[[ -n "$norm" ]] || return 0
|
[[ -n "$norm" ]] || return 0
|
||||||
if [[ "$norm" == *"*"* ]]; then
|
if [[ "$norm" == *"*"* ]]; then
|
||||||
local re
|
local re="^$(_manifest_glob_to_ere "$norm")\$"
|
||||||
re="^$(_manifest_glob_to_ere "$norm")\$"
|
|
||||||
if [[ "$2" == F ]]; then
|
if [[ "$2" == F ]]; then
|
||||||
_MF_KIND+=(re); _MF_EXACT+=(""); _MF_RE+=("$re")
|
_MF_KIND+=(re); _MF_EXACT+=(""); _MF_RE+=("$re")
|
||||||
else
|
else
|
||||||
@@ -184,10 +183,7 @@ _mo_matches() {
|
|||||||
for (( i = 0; i < n; i++ )); do
|
for (( i = 0; i < n; i++ )); do
|
||||||
if [[ "${_MO_KIND[i]}" == exact ]]; then
|
if [[ "${_MO_KIND[i]}" == exact ]]; then
|
||||||
pat="${_MO_EXACT[i]}"
|
pat="${_MO_EXACT[i]}"
|
||||||
# Operator exact entries are file carve-outs, not implicit directory
|
[[ "$path" == "$pat" || "$path" == "$pat/"* ]] && return 0
|
||||||
# prefixes. Subtree ownership must be declared explicitly as `dir/**`;
|
|
||||||
# otherwise one bare directory entry can hide all drift beneath it.
|
|
||||||
[[ "$path" == "$pat" ]] && return 0
|
|
||||||
else
|
else
|
||||||
re="${_MO_RE[i]}"
|
re="${_MO_RE[i]}"
|
||||||
[[ "$path" =~ $re ]] && return 0
|
[[ "$path" =~ $re ]] && return 0
|
||||||
|
|||||||
@@ -155,38 +155,6 @@ warn_if_symlink_tree_present() {
|
|||||||
|
|
||||||
echo "[mosaic-doctor] Mosaic home: $MOSAIC_HOME"
|
echo "[mosaic-doctor] Mosaic home: $MOSAIC_HOME"
|
||||||
|
|
||||||
# Compare the framework tools that this CLI/package ships with the deployed
|
|
||||||
# ~/.config copy that direct wrappers and systemd units actually execute. Doctor
|
|
||||||
# is the right boundary: observational, operator-invoked, and already designed
|
|
||||||
# to report drift without mutating live tooling or restarting active seats.
|
|
||||||
framework_drift_checker="$(cd -- "$(dirname -- "$0")/../quality/scripts" && pwd)/framework-drift-check.py"
|
|
||||||
if [[ -f "$framework_drift_checker" ]]; then
|
|
||||||
echo "[mosaic-doctor] Checking installed framework-tool drift..."
|
|
||||||
drift_timeout="${MOSAIC_DOCTOR_DRIFT_TIMEOUT_SEC:-15}"
|
|
||||||
if ! [[ "$drift_timeout" =~ ^[1-9][0-9]*$ ]]; then
|
|
||||||
warn "Invalid MOSAIC_DOCTOR_DRIFT_TIMEOUT_SEC='$drift_timeout' (expected positive integer); using 15s"
|
|
||||||
drift_timeout=15
|
|
||||||
fi
|
|
||||||
if command -v timeout >/dev/null 2>&1; then
|
|
||||||
set +e
|
|
||||||
timeout -s TERM -k 2 "${drift_timeout}s" \
|
|
||||||
python3 "$framework_drift_checker" --installed-root "$MOSAIC_HOME/tools"
|
|
||||||
drift_rc=$?
|
|
||||||
set -e
|
|
||||||
if [[ "$drift_rc" -eq 0 ]]; then
|
|
||||||
pass "Installed framework tools match shipped source"
|
|
||||||
elif [[ "$drift_rc" -eq 124 || "$drift_rc" -eq 137 || "$drift_rc" -eq 143 ]]; then
|
|
||||||
warn "CANNOT_ASSERT framework drift checker timed out after ${drift_timeout}s; continuing remaining doctor checks"
|
|
||||||
else
|
|
||||||
warn "Installed framework-tool drift detected (checker exit $drift_rc; no files changed)"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
warn "CANNOT_ASSERT timeout utility unavailable; refusing unbounded framework drift check and continuing remaining doctor checks"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
warn "Framework drift checker is absent from the shipped tools tree"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Canonical Mosaic checks
|
# Canonical Mosaic checks
|
||||||
expect_file "$MOSAIC_HOME/STANDARDS.md"
|
expect_file "$MOSAIC_HOME/STANDARDS.md"
|
||||||
expect_file "$MOSAIC_HOME/USER.md"
|
expect_file "$MOSAIC_HOME/USER.md"
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ if [[ -n "$GROUP" ]]; then
|
|||||||
group_response=$(curl -sk \
|
group_response=$(curl -sk \
|
||||||
-H "Authorization: Bearer $TOKEN" \
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
"${AUTHENTIK_URL}/api/v3/core/groups/?search=${GROUP}")
|
"${AUTHENTIK_URL}/api/v3/core/groups/?search=${GROUP}")
|
||||||
group_pk=$(jq -r "first(.results[] | select(.name == \"$GROUP\") | .pk) // empty" <<<"$group_response")
|
group_pk=$(echo "$group_response" | jq -r ".results[] | select(.name == \"$GROUP\") | .pk" | head -1)
|
||||||
if [[ -n "$group_pk" ]]; then
|
if [[ -n "$group_pk" ]]; then
|
||||||
payload=$(echo "$payload" | jq --arg gk "$group_pk" '. + {groups: [$gk]}')
|
payload=$(echo "$payload" | jq --arg gk "$group_pk" '. + {groups: [$gk]}')
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ is_sensitive_key() {
|
|||||||
|
|
||||||
is_generated_key() {
|
is_generated_key() {
|
||||||
case "$1" in
|
case "$1" in
|
||||||
MOSAIC_AGENT_NAME|MOSAIC_GIT_IDENTITY|MOSAIC_AGENT_CLASS|MOSAIC_AGENT_RUNTIME|MOSAIC_AGENT_MODEL|MOSAIC_AGENT_REASONING|MOSAIC_AGENT_TOOL_POLICY|MOSAIC_AGENT_WORKDIR|MOSAIC_TMUX_SOCKET) return 0 ;;
|
MOSAIC_AGENT_NAME|MOSAIC_AGENT_CLASS|MOSAIC_AGENT_RUNTIME|MOSAIC_AGENT_MODEL|MOSAIC_AGENT_REASONING|MOSAIC_AGENT_TOOL_POLICY|MOSAIC_AGENT_WORKDIR|MOSAIC_TMUX_SOCKET) return 0 ;;
|
||||||
*) return 1 ;;
|
*) return 1 ;;
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
@@ -134,7 +134,6 @@ validate_generated_value() {
|
|||||||
local value="$2"
|
local value="$2"
|
||||||
case "$key" in
|
case "$key" in
|
||||||
MOSAIC_AGENT_NAME) safe_agent_name "$value" || fail_env unsafe-agent-name "$key" "$value" ;;
|
MOSAIC_AGENT_NAME) safe_agent_name "$value" || fail_env unsafe-agent-name "$key" "$value" ;;
|
||||||
MOSAIC_GIT_IDENTITY) safe_agent_name "$value" || fail_env unsafe-git-identity "$key" "$value" ;;
|
|
||||||
MOSAIC_AGENT_CLASS) safe_policy_name "$value" || fail_env unsafe-class "$key" "$value" ;;
|
MOSAIC_AGENT_CLASS) safe_policy_name "$value" || fail_env unsafe-class "$key" "$value" ;;
|
||||||
MOSAIC_AGENT_RUNTIME)
|
MOSAIC_AGENT_RUNTIME)
|
||||||
case "$value" in claude|codex|opencode|pi) ;; *) fail_env unsupported-runtime "$key" "$value" ;; esac
|
case "$value" in claude|codex|opencode|pi) ;; *) fail_env unsupported-runtime "$key" "$value" ;; esac
|
||||||
@@ -196,7 +195,7 @@ load_environment_file() {
|
|||||||
|
|
||||||
load_environment_file "$GENERATED_ENV" generated
|
load_environment_file "$GENERATED_ENV" generated
|
||||||
for required_key in \
|
for required_key in \
|
||||||
MOSAIC_AGENT_NAME MOSAIC_GIT_IDENTITY MOSAIC_AGENT_CLASS MOSAIC_AGENT_RUNTIME MOSAIC_AGENT_MODEL \
|
MOSAIC_AGENT_NAME MOSAIC_AGENT_CLASS MOSAIC_AGENT_RUNTIME MOSAIC_AGENT_MODEL \
|
||||||
MOSAIC_AGENT_REASONING MOSAIC_AGENT_TOOL_POLICY MOSAIC_AGENT_WORKDIR MOSAIC_TMUX_SOCKET; do
|
MOSAIC_AGENT_REASONING MOSAIC_AGENT_TOOL_POLICY MOSAIC_AGENT_WORKDIR MOSAIC_TMUX_SOCKET; do
|
||||||
[ -n "${GENERATED_VALUES[$required_key]+set}" ] || fail_env missing-key "$required_key" ''
|
[ -n "${GENERATED_VALUES[$required_key]+set}" ] || fail_env missing-key "$required_key" ''
|
||||||
done
|
done
|
||||||
@@ -204,15 +203,12 @@ load_environment_file "$LOCAL_ENV" local
|
|||||||
|
|
||||||
[ "${GENERATED_VALUES[MOSAIC_AGENT_NAME]}" = "$AGENT_NAME" ] || \
|
[ "${GENERATED_VALUES[MOSAIC_AGENT_NAME]}" = "$AGENT_NAME" ] || \
|
||||||
fail_env agent-name-mismatch MOSAIC_AGENT_NAME "${GENERATED_VALUES[MOSAIC_AGENT_NAME]}"
|
fail_env agent-name-mismatch MOSAIC_AGENT_NAME "${GENERATED_VALUES[MOSAIC_AGENT_NAME]}"
|
||||||
[ "${GENERATED_VALUES[MOSAIC_GIT_IDENTITY]}" = "$AGENT_NAME" ] || \
|
|
||||||
fail_env git-identity-mismatch MOSAIC_GIT_IDENTITY "${GENERATED_VALUES[MOSAIC_GIT_IDENTITY]}"
|
|
||||||
|
|
||||||
MOSAIC_TMUX_SOCKET=${GENERATED_VALUES[MOSAIC_TMUX_SOCKET]}
|
MOSAIC_TMUX_SOCKET=${GENERATED_VALUES[MOSAIC_TMUX_SOCKET]}
|
||||||
MOSAIC_AGENT_RUNTIME=${GENERATED_VALUES[MOSAIC_AGENT_RUNTIME]}
|
MOSAIC_AGENT_RUNTIME=${GENERATED_VALUES[MOSAIC_AGENT_RUNTIME]}
|
||||||
MOSAIC_AGENT_MODEL=${GENERATED_VALUES[MOSAIC_AGENT_MODEL]}
|
MOSAIC_AGENT_MODEL=${GENERATED_VALUES[MOSAIC_AGENT_MODEL]}
|
||||||
MOSAIC_AGENT_REASONING=${GENERATED_VALUES[MOSAIC_AGENT_REASONING]}
|
MOSAIC_AGENT_REASONING=${GENERATED_VALUES[MOSAIC_AGENT_REASONING]}
|
||||||
MOSAIC_AGENT_WORKDIR=${GENERATED_VALUES[MOSAIC_AGENT_WORKDIR]}
|
MOSAIC_AGENT_WORKDIR=${GENERATED_VALUES[MOSAIC_AGENT_WORKDIR]}
|
||||||
MOSAIC_GIT_IDENTITY=${GENERATED_VALUES[MOSAIC_GIT_IDENTITY]}
|
|
||||||
MOSAIC_AGENT_CLASS=${GENERATED_VALUES[MOSAIC_AGENT_CLASS]}
|
MOSAIC_AGENT_CLASS=${GENERATED_VALUES[MOSAIC_AGENT_CLASS]}
|
||||||
MOSAIC_AGENT_TOOL_POLICY=${GENERATED_VALUES[MOSAIC_AGENT_TOOL_POLICY]}
|
MOSAIC_AGENT_TOOL_POLICY=${GENERATED_VALUES[MOSAIC_AGENT_TOOL_POLICY]}
|
||||||
MOSAIC_RUNTIME_BIN=${LOCAL_VALUES[MOSAIC_RUNTIME_BIN]:-}
|
MOSAIC_RUNTIME_BIN=${LOCAL_VALUES[MOSAIC_RUNTIME_BIN]:-}
|
||||||
@@ -257,36 +253,8 @@ assert_owned_tmux_server() {
|
|||||||
fail "tmux server ownership or environment validation failed"
|
fail "tmux server ownership or environment validation failed"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Lease-broker socket preflight (#1292). The gated runtime (`mosaic yolo …` →
|
# Validate exact server ownership before querying, cleaning, or creating any
|
||||||
# launch-runtime.py) registers with the broker or dies ~4 seconds in, with the
|
# managed session. An unmanaged or contaminated named socket is never repaired.
|
||||||
# diagnostic invisible because tmux destroys the dead pane. This check runs
|
|
||||||
# BEFORE any tmux effect — including the ownership probe below — so a host
|
|
||||||
# without a broker produces a named, surviving refusal instead of a doomed
|
|
||||||
# pane. Exit 75 (EX_TEMPFAIL), distinct from 64 (bad projection) and 69 (host
|
|
||||||
# not ready for other reasons); the agent@ unit is Type=oneshot with no
|
|
||||||
# Restart=, so the failed unit keeps its message instead of looping. Socket
|
|
||||||
# resolution matches launch.ts's defaultLeaseBrokerSocket precedence exactly.
|
|
||||||
# This preflight DETECTS and REFUSES — it never starts the broker (activation
|
|
||||||
# belongs to the fleet control plane; a component that both detects and fixes
|
|
||||||
# cannot be used to measure whether the fix worked).
|
|
||||||
broker_socket_path() {
|
|
||||||
if [ -n "${MOSAIC_LEASE_BROKER_SOCKET:-}" ]; then
|
|
||||||
printf '%s\n' "$MOSAIC_LEASE_BROKER_SOCKET"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
local runtime_dir="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"
|
|
||||||
printf '%s\n' "${runtime_dir}/mosaic-lease/broker.sock"
|
|
||||||
}
|
|
||||||
|
|
||||||
if [ "$MODE" = "launch" ]; then
|
|
||||||
_broker_socket=$(broker_socket_path)
|
|
||||||
if [ ! -S "$_broker_socket" ]; then
|
|
||||||
echo "[fleet] FAIL_LAUNCH broker-absent: lease broker socket ${_broker_socket} missing; runtime launch denied (#1292)." >&2
|
|
||||||
echo "[fleet] remedy: systemctl --user enable --now mosaic-lease-broker.service (or reinstall via: mosaic fleet install)" >&2
|
|
||||||
exit 75
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
assert_owned_tmux_server
|
assert_owned_tmux_server
|
||||||
|
|
||||||
if [ "$MODE" = interaction ]; then
|
if [ "$MODE" = interaction ]; then
|
||||||
@@ -320,16 +288,6 @@ esac
|
|||||||
_build_runtime_bin_prefix() {
|
_build_runtime_bin_prefix() {
|
||||||
local candidates=()
|
local candidates=()
|
||||||
if [ -n "$MOSAIC_RUNTIME_BIN" ]; then candidates+=("$MOSAIC_RUNTIME_BIN"); fi
|
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
|
if command -v npm >/dev/null 2>&1; then
|
||||||
local npm_prefix
|
local npm_prefix
|
||||||
npm_prefix=$(npm config get prefix 2>/dev/null) || true
|
npm_prefix=$(npm config get prefix 2>/dev/null) || true
|
||||||
@@ -435,7 +393,6 @@ LAUNCH_ENV=(
|
|||||||
"PATH=$PANE_PATH"
|
"PATH=$PANE_PATH"
|
||||||
"MOSAIC_HOME=$MOSAIC_HOME"
|
"MOSAIC_HOME=$MOSAIC_HOME"
|
||||||
"MOSAIC_AGENT_NAME=$AGENT_NAME"
|
"MOSAIC_AGENT_NAME=$AGENT_NAME"
|
||||||
"MOSAIC_GIT_IDENTITY=$MOSAIC_GIT_IDENTITY"
|
|
||||||
"MOSAIC_AGENT_CLASS=$MOSAIC_AGENT_CLASS"
|
"MOSAIC_AGENT_CLASS=$MOSAIC_AGENT_CLASS"
|
||||||
"MOSAIC_AGENT_RUNTIME=$MOSAIC_AGENT_RUNTIME"
|
"MOSAIC_AGENT_RUNTIME=$MOSAIC_AGENT_RUNTIME"
|
||||||
"MOSAIC_AGENT_MODEL=$MOSAIC_AGENT_MODEL"
|
"MOSAIC_AGENT_MODEL=$MOSAIC_AGENT_MODEL"
|
||||||
|
|||||||
@@ -1,222 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# CI-fit regression suite for the #1292 lease-broker socket preflight in
|
|
||||||
# start-agent-session.sh.
|
|
||||||
#
|
|
||||||
# WHY THIS SUITE IS CI-FIT WHERE test-start-agent-session.sh IS NOT (#1017/#1270
|
|
||||||
# context): that older suite's precondition is "the host does not have the pi
|
|
||||||
# binary", which a CI image that ships pi violates — its guard correctly
|
|
||||||
# refuses to report a pass there, so it is excluded from the chain. THIS suite
|
|
||||||
# controls its own preconditions instead of inheriting them from the host: a
|
|
||||||
# fake tmux on PATH, a fake mosaic on PATH, a real unix socket created in a
|
|
||||||
# tmpdir, a hermetic env (env -i, fake HOME, GIT_CONFIG_GLOBAL severed). It
|
|
||||||
# never depends on what the host has installed, so a green here means the same
|
|
||||||
# thing on every host. Anyone adding cases: keep that property — no case may
|
|
||||||
# depend on host state.
|
|
||||||
#
|
|
||||||
# The failure this suite is written down to catch (#1292): a seat launched on a
|
|
||||||
# host with no lease broker dies ~4 seconds in at registration, with the
|
|
||||||
# diagnostic invisible because tmux destroys the dead pane. The preflight runs
|
|
||||||
# BEFORE any tmux effect and refuses with a NAMED code (exit 75, EX_TEMPFAIL)
|
|
||||||
# so the message survives. The agent@ unit is Type=oneshot with no Restart=,
|
|
||||||
# so a failed unit keeps its output instead of looping.
|
|
||||||
#
|
|
||||||
# Cases:
|
|
||||||
# 1. absent socket -> exit 75, message names broker-absent + socket path +
|
|
||||||
# remedy, and NO tmux session was ever created (the doomed-pane half).
|
|
||||||
# 2. present socket (real unix socket in tmpdir) -> proceeds PAST the
|
|
||||||
# preflight (the suite then stops at the next precondition, proving the
|
|
||||||
# preflight was not the refusal).
|
|
||||||
# 3. explicit MOSAIC_LEASE_BROKER_SOCKET wins over XDG_RUNTIME_DIR default.
|
|
||||||
# 4. --stop mode does NOT require the broker (teardown must not be fenced on
|
|
||||||
# a component whose absence is exactly what teardown may follow).
|
|
||||||
#
|
|
||||||
# Sabotage control, run by the developer (not in-suite): remove the preflight
|
|
||||||
# block from start-agent-session.sh, re-run — case 1 fails (a tmux session is
|
|
||||||
# created / exit is not 75), cases 2-4 still pass; restore byte-identically.
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/agent-session-broker-preflight}"
|
|
||||||
FAKE_HOME="$WORK_DIR/home"
|
|
||||||
BIN_DIR="$WORK_DIR/bin"
|
|
||||||
ENV_DIR="$WORK_DIR/env"
|
|
||||||
SOCK_DIR="$WORK_DIR/sockets"
|
|
||||||
LOG_FILE="$WORK_DIR/tmux-calls.log"
|
|
||||||
|
|
||||||
rm -rf "$WORK_DIR"
|
|
||||||
# The script asserts a managed directory tree under MOSAIC_HOME: mosaic/,
|
|
||||||
# mosaic/fleet/, mosaic/fleet/agents/ — private (0700/0750-style) modes, no
|
|
||||||
# symlinks — plus a per-agent env projection. Build the full tree the launcher
|
|
||||||
# expects so the suite reaches the BROKER preflight rather than dying at
|
|
||||||
# environment validation.
|
|
||||||
mkdir -p "$FAKE_HOME/.config/mosaic/fleet/agents" "$BIN_DIR" "$SOCK_DIR"
|
|
||||||
chmod 700 "$FAKE_HOME/.config/mosaic" "$FAKE_HOME/.config/mosaic/fleet/agents"
|
|
||||||
chmod 750 "$FAKE_HOME/.config/mosaic/fleet"
|
|
||||||
cat > "$FAKE_HOME/.config/mosaic/fleet/agents/preflight-test.env.generated" <<'ENVEOF'
|
|
||||||
MOSAIC_AGENT_NAME=preflight-test
|
|
||||||
MOSAIC_GIT_IDENTITY=preflight-test
|
|
||||||
MOSAIC_AGENT_CLASS=worker
|
|
||||||
MOSAIC_AGENT_RUNTIME=pi
|
|
||||||
MOSAIC_AGENT_MODEL=
|
|
||||||
MOSAIC_AGENT_REASONING=
|
|
||||||
MOSAIC_AGENT_TOOL_POLICY=code
|
|
||||||
MOSAIC_AGENT_WORKDIR=/tmp
|
|
||||||
MOSAIC_TMUX_SOCKET=mosaic-fleet
|
|
||||||
ENVEOF
|
|
||||||
chmod 600 "$FAKE_HOME/.config/mosaic/fleet/agents/preflight-test.env.generated"
|
|
||||||
|
|
||||||
# ─── Fake tmux: records every invocation; new-session marks the marker. ────
|
|
||||||
: > "$LOG_FILE"
|
|
||||||
cat > "$BIN_DIR/tmux" <<SH
|
|
||||||
#!/usr/bin/env bash
|
|
||||||
printf 'tmux %s\n' "\$*" >> "$LOG_FILE"
|
|
||||||
if [[ "\$*" == *new-session* ]]; then
|
|
||||||
echo "TMUX-NEW-SESSION-INVOKED" >> "$LOG_FILE"
|
|
||||||
fi
|
|
||||||
exit 0
|
|
||||||
SH
|
|
||||||
chmod +x "$BIN_DIR/tmux"
|
|
||||||
|
|
||||||
# ─── Fake mosaic/pi binaries so the script proceeds past its own lookups. ───
|
|
||||||
for bin in mosaic pi claude; do
|
|
||||||
printf '#!/usr/bin/env bash\nexit 0\n' > "$BIN_DIR/$bin"
|
|
||||||
chmod +x "$BIN_DIR/$bin"
|
|
||||||
done
|
|
||||||
|
|
||||||
# ─── Minimal launch environment the script expects. ────────────────────────
|
|
||||||
# (Enough for the preflight to be reached; later stages will still fail in
|
|
||||||
# case 2 — that is expected and asserted.)
|
|
||||||
run_session_script() {
|
|
||||||
local mode="$1"; shift
|
|
||||||
(
|
|
||||||
cd "$WORK_DIR"
|
|
||||||
env -i HOME="$FAKE_HOME" PATH="$BIN_DIR:/usr/bin:/bin" \
|
|
||||||
GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null \
|
|
||||||
MOSAIC_HOME="$FAKE_HOME/.config/mosaic" \
|
|
||||||
AGENT_NAME=preflight-test \
|
|
||||||
"$@" \
|
|
||||||
bash "$SCRIPT_DIR/start-agent-session.sh" $mode preflight-test
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fail=0
|
|
||||||
assert() {
|
|
||||||
local desc="$1" expected="$2" actual="$3"
|
|
||||||
if [[ "$expected" != "$actual" ]]; then
|
|
||||||
echo "FAIL: $desc — expected '$expected', got '$actual'" >&2
|
|
||||||
fail=1
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
assert_contains() {
|
|
||||||
local desc="$1" haystack="$2" needle="$3"
|
|
||||||
[[ "$haystack" == *"$needle"* ]] || { echo "FAIL: $desc — missing '$needle' in: $haystack" >&2; fail=1; }
|
|
||||||
}
|
|
||||||
assert_not_contains() {
|
|
||||||
local desc="$1" haystack="$2" needle="$3"
|
|
||||||
if [[ "$haystack" == *"$needle"* ]]; then
|
|
||||||
echo "FAIL: $desc — must not contain '$needle'" >&2
|
|
||||||
fail=1
|
|
||||||
fi
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# ─── 1. Absent socket → named refusal, NO tmux session. ────────────────────
|
|
||||||
: > "$LOG_FILE"
|
|
||||||
stderr_file="$WORK_DIR/stderr-1.tmp"
|
|
||||||
set +e
|
|
||||||
out=$(run_session_script "" MOSAIC_LEASE_BROKER_SOCKET="$SOCK_DIR/absent.sock" 2>"$stderr_file")
|
|
||||||
rc=$?
|
|
||||||
set -e
|
|
||||||
assert "absent socket exit code" "75" "$rc"
|
|
||||||
err=$(cat "$stderr_file")
|
|
||||||
assert_contains "absent socket names the failure" "$err" "FAIL_LAUNCH broker-absent"
|
|
||||||
assert_contains "absent socket names the socket path" "$err" "$SOCK_DIR/absent.sock"
|
|
||||||
assert_contains "absent socket names a remedy" "$err" "mosaic fleet install"
|
|
||||||
log1=$(cat "$LOG_FILE")
|
|
||||||
assert_not_contains "absent socket must not create a tmux session" "$log1" "TMUX-NEW-SESSION-INVOKED"
|
|
||||||
|
|
||||||
# ─── 2. Present socket → passes the preflight. ─────────────────────────────
|
|
||||||
# Expected: ownership/env checks AFTER the preflight may refuse (fixture is
|
|
||||||
# minimal by design); the assertion is only that the refusal is NOT
|
|
||||||
# broker-absent and the exit is NOT 75.
|
|
||||||
# Create a REAL unix socket: a detached python holder binds it and stays alive
|
|
||||||
# for the duration (bash cannot create sockets; a foreground python would
|
|
||||||
# close the socket on exit and -S on a closed-but-unlinked path fails). Written
|
|
||||||
# as a script file + setsid nohup so no job-control/heredoc interaction with
|
|
||||||
# set -e can silently kill the suite.
|
|
||||||
# AF_UNIX binds cap at 108 path bytes; the suite's workdir exceeds that, so
|
|
||||||
# the live socket lives at a SHORT path under /tmp (unique per run, cleaned
|
|
||||||
# with the suite). The preflight takes its socket path explicitly, so this
|
|
||||||
# stays fully controlled.
|
|
||||||
# A real unix socket at a SHORT absolute path (AF_UNIX limit is 108 bytes,
|
|
||||||
# so the repo-deep SOCK_DIR cannot host it). The name is composed, not
|
|
||||||
# `mktemp -u`: the CI image's mktemp dialect rejects that invocation
|
|
||||||
# (pipeline 2562: "mktemp: : Invalid argument"), and no pre-existing file is
|
|
||||||
# wanted anyway — the holder binds it fresh.
|
|
||||||
LIVE_SOCK="/tmp/mosaic-preflight-$RANDOM-$$.sock"
|
|
||||||
trap 'rm -f "$LIVE_SOCK"' EXIT
|
|
||||||
rm -f "$SOCK_DIR/live.sock" "$LIVE_SOCK"
|
|
||||||
cat > "$SOCK_DIR/holder.py" <<'PY'
|
|
||||||
import socket, sys, time
|
|
||||||
path = sys.argv[1]
|
|
||||||
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
||||||
s.bind(path)
|
|
||||||
s.listen(1)
|
|
||||||
time.sleep(120)
|
|
||||||
PY
|
|
||||||
python3 "$SOCK_DIR/holder.py" "$LIVE_SOCK" >/dev/null 2>"$SOCK_DIR/holder.err" &
|
|
||||||
HOLDER_PID=$!
|
|
||||||
# Wait for the socket object to exist (bind is near-instant, but do not race it).
|
|
||||||
for _ in $(seq 1 50); do
|
|
||||||
[ -S "$LIVE_SOCK" ] && break
|
|
||||||
sleep 0.1
|
|
||||||
done
|
|
||||||
if [ ! -S "$LIVE_SOCK" ]; then
|
|
||||||
echo "FAIL: could not create live socket fixture (holder pid $HOLDER_PID)" >&2
|
|
||||||
ps -p "$HOLDER_PID" -o pid,stat,cmd --no-headers >&2 || echo "(holder exited)" >&2
|
|
||||||
cat "$SOCK_DIR/holder.err" >&2 || true
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
: > "$LOG_FILE"
|
|
||||||
set +e
|
|
||||||
out=$(run_session_script "" MOSAIC_LEASE_BROKER_SOCKET="$LIVE_SOCK" 2>"$WORK_DIR/stderr-2.tmp")
|
|
||||||
rc=$?
|
|
||||||
set -e
|
|
||||||
# The preflight PASSED if the failure (whatever later stage refused) is NOT
|
|
||||||
# the broker refusal, and tmux was reached or a later precondition named
|
|
||||||
# something else.
|
|
||||||
err2=$(cat "$WORK_DIR/stderr-2.tmp")
|
|
||||||
assert_not_contains "live socket must not refuse broker-absent" "$err2" "broker-absent"
|
|
||||||
if [[ "$rc" == "75" ]]; then
|
|
||||||
echo "FAIL: live socket — preflight still refused (exit 75) with a live socket" >&2
|
|
||||||
fail=1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ─── 3. Explicit socket env wins over XDG default. ─────────────────────────
|
|
||||||
set +e
|
|
||||||
out=$(run_session_script "" XDG_RUNTIME_DIR="$SOCK_DIR/no-runtime-here" MOSAIC_LEASE_BROKER_SOCKET="$SOCK_DIR/absent2.sock" 2>"$WORK_DIR/stderr-3.tmp")
|
|
||||||
rc=$?
|
|
||||||
set -e
|
|
||||||
assert "explicit env wins (exit 75)" "75" "$rc"
|
|
||||||
assert_contains "explicit env path named" "$(cat "$WORK_DIR/stderr-3.tmp")" "$SOCK_DIR/absent2.sock"
|
|
||||||
|
|
||||||
# ─── 4. --stop is not fenced on the broker. ────────────────────────────────
|
|
||||||
: > "$LOG_FILE"
|
|
||||||
set +e
|
|
||||||
out=$(run_session_script "--stop" MOSAIC_LEASE_BROKER_SOCKET="$SOCK_DIR/absent3.sock" 2>"$WORK_DIR/stderr-4.tmp")
|
|
||||||
rc=$?
|
|
||||||
set -e
|
|
||||||
err4=$(cat "$WORK_DIR/stderr-4.tmp")
|
|
||||||
assert_not_contains "--stop must not refuse broker-absent" "$err4" "broker-absent"
|
|
||||||
if [[ "$rc" == "75" ]]; then
|
|
||||||
echo "FAIL: --stop — exit 75 means teardown was fenced on the broker" >&2
|
|
||||||
fail=1
|
|
||||||
fi
|
|
||||||
|
|
||||||
kill "$HOLDER_PID" 2>/dev/null || true
|
|
||||||
|
|
||||||
if [[ "$fail" -eq 0 ]]; then
|
|
||||||
echo "start-agent-session lease-broker preflight regression passed"
|
|
||||||
fi
|
|
||||||
exit "$fail"
|
|
||||||
@@ -14,82 +14,6 @@ fail() {
|
|||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
pane_command_clears_environment() {
|
|
||||||
local calls_file="$1"
|
|
||||||
local -a argv=()
|
|
||||||
local index
|
|
||||||
mapfile -d '' -t argv < "$calls_file"
|
|
||||||
for ((index = 0; index + 1 < ${#argv[@]}; index++)); do
|
|
||||||
if [ "${argv[$index]}" = /usr/bin/env ] && [ "${argv[$((index + 1))]}" = -i ]; then
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
print_pane_argv() {
|
|
||||||
local calls_file="$1"
|
|
||||||
local -a argv=()
|
|
||||||
local bytes index
|
|
||||||
mapfile -d '' -t argv < "$calls_file"
|
|
||||||
bytes=$(wc -c < "$calls_file")
|
|
||||||
printf 'observed pane argv: records=%s bytes=%s\n' "${#argv[@]}" "$bytes" >&2
|
|
||||||
for ((index = 0; index < ${#argv[@]}; index++)); do
|
|
||||||
printf ' [%03d] %q\n' "$index" "${argv[$index]}" >&2
|
|
||||||
done
|
|
||||||
}
|
|
||||||
|
|
||||||
check_pane_environment_boundary() {
|
|
||||||
local calls_file="$1"
|
|
||||||
if pane_command_clears_environment "$calls_file"; then
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
print_pane_argv "$calls_file"
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
contains_literal() {
|
|
||||||
grep -F -- "$2" <<< "$1" >/dev/null
|
|
||||||
}
|
|
||||||
|
|
||||||
contains_line() {
|
|
||||||
grep -xF -- "$2" <<< "$1" >/dev/null
|
|
||||||
}
|
|
||||||
|
|
||||||
# Portability regression: inspect the authoritative NUL-delimited argv instead
|
|
||||||
# of piping a newline reconstruction through `grep -q` under pipefail. The old
|
|
||||||
# pipeline could report failure after a successful match when an upstream
|
|
||||||
# producer received SIGPIPE. A large trailing argument keeps that failure class
|
|
||||||
# covered without making stream size part of the semantic contract.
|
|
||||||
PORTABILITY_CALLS="$ROOT/portability-calls"
|
|
||||||
printf -v PORTABILITY_PADDING '%*s' 32768 ''
|
|
||||||
PORTABILITY_PADDING=${PORTABILITY_PADDING// /x}
|
|
||||||
printf '%s\0' /usr/bin/env -i "$PORTABILITY_PADDING" > "$PORTABILITY_CALLS"
|
|
||||||
pane_command_clears_environment "$PORTABILITY_CALLS" || \
|
|
||||||
fail "valid large pane argv was rejected by the environment-boundary assertion"
|
|
||||||
|
|
||||||
assert_pane_boundary_rejected() {
|
|
||||||
local case_name="$1"
|
|
||||||
local expected_records="$2"
|
|
||||||
local diagnostic
|
|
||||||
if diagnostic=$(check_pane_environment_boundary "$PORTABILITY_CALLS" 2>&1); then
|
|
||||||
fail "pane boundary accepted invalid $case_name fixture"
|
|
||||||
fi
|
|
||||||
contains_literal "$diagnostic" "records=$expected_records bytes=" || \
|
|
||||||
fail "pane argv diagnostic omitted counts for $case_name fixture"
|
|
||||||
contains_literal "$diagnostic" '[000]' || \
|
|
||||||
fail "pane argv diagnostic omitted indexed arguments for $case_name fixture"
|
|
||||||
}
|
|
||||||
|
|
||||||
printf '%s\0' tmux -i > "$PORTABILITY_CALLS"
|
|
||||||
assert_pane_boundary_rejected missing-env 2
|
|
||||||
printf '%s\0' /usr/bin/env HOME=/untrusted > "$PORTABILITY_CALLS"
|
|
||||||
assert_pane_boundary_rejected missing-i 2
|
|
||||||
printf '%s\0' /usr/bin/env HOME=/untrusted -i > "$PORTABILITY_CALLS"
|
|
||||||
assert_pane_boundary_rejected non-adjacent-i 3
|
|
||||||
printf '%s\0' -i /usr/bin/env > "$PORTABILITY_CALLS"
|
|
||||||
assert_pane_boundary_rejected reversed-boundary 2
|
|
||||||
|
|
||||||
cat > "$FAKE_BIN/tmux" <<'SHIM'
|
cat > "$FAKE_BIN/tmux" <<'SHIM'
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -189,7 +113,6 @@ write_generated() {
|
|||||||
chmod 600 "$home/fleet/run/holder-owner"
|
chmod 600 "$home/fleet/run/holder-owner"
|
||||||
cat > "$home/fleet/agents/$agent.env.generated" <<EOF
|
cat > "$home/fleet/agents/$agent.env.generated" <<EOF
|
||||||
MOSAIC_AGENT_NAME=$agent
|
MOSAIC_AGENT_NAME=$agent
|
||||||
MOSAIC_GIT_IDENTITY=$agent
|
|
||||||
MOSAIC_AGENT_CLASS=code
|
MOSAIC_AGENT_CLASS=code
|
||||||
MOSAIC_AGENT_RUNTIME=pi
|
MOSAIC_AGENT_RUNTIME=pi
|
||||||
MOSAIC_AGENT_MODEL=openai-codex/gpt-5.6-sol
|
MOSAIC_AGENT_MODEL=openai-codex/gpt-5.6-sol
|
||||||
@@ -221,7 +144,6 @@ run_start() {
|
|||||||
HOME="$home" PATH="$FAKE_BIN:$PATH" MOSAIC_TEST_TMUX_CALLS="$TMUX_CALLS" \
|
HOME="$home" PATH="$FAKE_BIN:$PATH" MOSAIC_TEST_TMUX_CALLS="$TMUX_CALLS" \
|
||||||
MOSAIC_TEST_PANE_PID="${MOSAIC_TEST_PANE_PID:-}" \
|
MOSAIC_TEST_PANE_PID="${MOSAIC_TEST_PANE_PID:-}" \
|
||||||
MOSAIC_TEST_HELD_SESSIONS="${MOSAIC_TEST_HELD_SESSIONS:-}" \
|
MOSAIC_TEST_HELD_SESSIONS="${MOSAIC_TEST_HELD_SESSIONS:-}" \
|
||||||
MOSAIC_TEST_FIXED_EPOCH="${MOSAIC_TEST_FIXED_EPOCH:-}" \
|
|
||||||
MOSAIC_TEST_HOME="$home" \
|
MOSAIC_TEST_HOME="$home" \
|
||||||
MOSAIC_TEST_FLEET_OWNER=123e4567-e89b-12d3-a456-426614174000 \
|
MOSAIC_TEST_FLEET_OWNER=123e4567-e89b-12d3-a456-426614174000 \
|
||||||
MOSAIC_HOME="$home" "$START" "$agent"
|
MOSAIC_HOME="$home" "$START" "$agent"
|
||||||
@@ -237,11 +159,11 @@ write_generated "$HOME_VALID" "$AGENT_VALID"
|
|||||||
# launcher reported as fine.
|
# launcher reported as fine.
|
||||||
MOSAIC_TEST_PANE_PID=$$ run_start "$HOME_VALID" "$AGENT_VALID"
|
MOSAIC_TEST_PANE_PID=$$ run_start "$HOME_VALID" "$AGENT_VALID"
|
||||||
valid_args=$(tr '\0' '\n' < "$TMUX_CALLS")
|
valid_args=$(tr '\0' '\n' < "$TMUX_CALLS")
|
||||||
contains_literal "$valid_args" new-session || fail "valid generated projection did not reach tmux"
|
echo "$valid_args" | grep -qF new-session || fail "valid generated projection did not reach tmux"
|
||||||
contains_literal "$valid_args" mosaic || fail "fixed mosaic launcher command missing"
|
echo "$valid_args" | grep -qF 'mosaic' || fail "fixed mosaic launcher command missing"
|
||||||
contains_literal "$valid_args" yolo || fail "fixed yolo launcher command missing"
|
echo "$valid_args" | grep -qF 'yolo' || fail "fixed yolo launcher command missing"
|
||||||
contains_literal "$valid_args" pi || fail "roster runtime missing"
|
echo "$valid_args" | grep -qF 'pi' || fail "roster runtime missing"
|
||||||
if contains_literal "$valid_args" 'bash -c'; then
|
if echo "$valid_args" | grep -qF 'bash -c'; then
|
||||||
fail "launcher constructed a shell command payload"
|
fail "launcher constructed a shell command payload"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -296,44 +218,8 @@ echo "$legacy_args" | grep -qF new-session || fail "legacy single-tree launch re
|
|||||||
# The pane must start through an absolute clean-environment boundary. Its
|
# The pane must start through an absolute clean-environment boundary. Its
|
||||||
# runtime command remains an argv vector, but no holder/session environment
|
# runtime command remains an argv vector, but no holder/session environment
|
||||||
# control variable can pass through the pane command.
|
# control variable can pass through the pane command.
|
||||||
check_pane_environment_boundary "$TMUX_CALLS" || \
|
echo "$valid_args" | grep -qxF '/usr/bin/env' || fail "pane does not use absolute env"
|
||||||
fail "pane command did not use an adjacent /usr/bin/env -i boundary"
|
echo "$valid_args" | grep -qxF -- '-i' || fail "pane environment is not cleared"
|
||||||
|
|
||||||
# Git identity is generated authority, not an optional or independently mutable
|
|
||||||
# local value. Each invalid form must fail before fake tmux receives a call.
|
|
||||||
assert_git_identity_rejected() {
|
|
||||||
local case_name="$1"
|
|
||||||
local expected_code="$2"
|
|
||||||
local home="$ROOT/git-identity-$case_name"
|
|
||||||
local agent="coder-git-identity-$case_name"
|
|
||||||
local generated="$home/fleet/agents/$agent.env.generated"
|
|
||||||
write_generated "$home" "$agent"
|
|
||||||
|
|
||||||
case "$case_name" in
|
|
||||||
missing) grep -v '^MOSAIC_GIT_IDENTITY=' "$generated" > "$generated.next" && mv "$generated.next" "$generated" ;;
|
|
||||||
unsafe) sed -i 's|^MOSAIC_GIT_IDENTITY=.*$|MOSAIC_GIT_IDENTITY=bad/identity|' "$generated" ;;
|
|
||||||
mismatch) sed -i 's|^MOSAIC_GIT_IDENTITY=.*$|MOSAIC_GIT_IDENTITY=other-agent|' "$generated" ;;
|
|
||||||
local-shadow)
|
|
||||||
printf 'MOSAIC_GIT_IDENTITY=%s\n' "$agent" > "$home/fleet/agents/$agent.env.local"
|
|
||||||
chmod 600 "$home/fleet/agents/$agent.env.local"
|
|
||||||
;;
|
|
||||||
*) fail "unknown Git identity rejection case: $case_name" ;;
|
|
||||||
esac
|
|
||||||
chmod 600 "$generated"
|
|
||||||
|
|
||||||
: > "$TMUX_CALLS"
|
|
||||||
if output=$(run_start "$home" "$agent" 2>&1); then
|
|
||||||
fail "Git identity case $case_name was accepted"
|
|
||||||
fi
|
|
||||||
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before Git identity $case_name rejection"
|
|
||||||
contains_literal "$output" "code=$expected_code" || \
|
|
||||||
fail "Git identity $case_name diagnostic omitted code $expected_code"
|
|
||||||
}
|
|
||||||
|
|
||||||
assert_git_identity_rejected missing missing-key
|
|
||||||
assert_git_identity_rejected unsafe unsafe-git-identity
|
|
||||||
assert_git_identity_rejected mismatch git-identity-mismatch
|
|
||||||
assert_git_identity_rejected local-shadow generated-key-shadow
|
|
||||||
|
|
||||||
# The generated-file parent is a security boundary too: even a private regular
|
# The generated-file parent is a security boundary too: even a private regular
|
||||||
# file is untrusted if its parent can be replaced or written by another user.
|
# file is untrusted if its parent can be replaced or written by another user.
|
||||||
@@ -346,7 +232,7 @@ if output=$(run_start "$HOME_UNSAFE_PARENT" coder-parent 2>&1); then
|
|||||||
fail "generated file under a world-writable parent was accepted"
|
fail "generated file under a world-writable parent was accepted"
|
||||||
fi
|
fi
|
||||||
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before unsafe parent rejection"
|
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before unsafe parent rejection"
|
||||||
contains_literal "$output" 'code=unsafe-permissions' || fail "unsafe parent diagnostic missing"
|
echo "$output" | grep -qF 'code=unsafe-permissions' || fail "unsafe parent diagnostic missing"
|
||||||
|
|
||||||
: > "$TMUX_CALLS"
|
: > "$TMUX_CALLS"
|
||||||
HOME_SYMLINK_PARENT="$ROOT/symlink-parent"
|
HOME_SYMLINK_PARENT="$ROOT/symlink-parent"
|
||||||
@@ -357,7 +243,7 @@ if output=$(run_start "$HOME_SYMLINK_PARENT" coder-symlink-parent 2>&1); then
|
|||||||
fail "generated file under a symlinked parent was accepted"
|
fail "generated file under a symlinked parent was accepted"
|
||||||
fi
|
fi
|
||||||
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before symlinked parent rejection"
|
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before symlinked parent rejection"
|
||||||
contains_literal "$output" 'code=unsafe-directory' || fail "symlinked parent diagnostic missing"
|
echo "$output" | grep -qF 'code=unsafe-directory' || fail "symlinked parent diagnostic missing"
|
||||||
|
|
||||||
# Every managed ancestor is a boundary: MOSAIC_HOME, fleet, and agents. A
|
# Every managed ancestor is a boundary: MOSAIC_HOME, fleet, and agents. A
|
||||||
# symlink or group/world-writable ancestor must fail before environment parsing,
|
# symlink or group/world-writable ancestor must fail before environment parsing,
|
||||||
@@ -395,8 +281,8 @@ assert_managed_ancestor_rejected() {
|
|||||||
fi
|
fi
|
||||||
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before $hazard $ancestor rejection"
|
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before $hazard $ancestor rejection"
|
||||||
[ ! -e "$home/work" ] || fail "workdir was created before $hazard $ancestor rejection"
|
[ ! -e "$home/work" ] || fail "workdir was created before $hazard $ancestor rejection"
|
||||||
contains_literal "$output" 'code=unsafe-' || fail "managed ancestor diagnostic missing"
|
echo "$output" | grep -qF "code=unsafe-" || fail "managed ancestor diagnostic missing"
|
||||||
if contains_literal "$output" 'key=MOSAIC_AGENT_COMMAND'; then
|
if echo "$output" | grep -qF 'key=MOSAIC_AGENT_COMMAND'; then
|
||||||
fail "environment parsing ran before $hazard $ancestor rejection"
|
fail "environment parsing ran before $hazard $ancestor rejection"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
@@ -417,9 +303,9 @@ if output=$(run_start "$HOME_SHADOW" coder1 2>&1); then
|
|||||||
fail "generated-key shadow was accepted"
|
fail "generated-key shadow was accepted"
|
||||||
fi
|
fi
|
||||||
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before generated-key shadow rejection"
|
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before generated-key shadow rejection"
|
||||||
contains_literal "$output" 'key=MOSAIC_AGENT_RUNTIME' || fail "shadow diagnostic omitted key"
|
echo "$output" | grep -qF 'key=MOSAIC_AGENT_RUNTIME' || fail "shadow diagnostic omitted key"
|
||||||
contains_literal "$output" 'sha256=' || fail "shadow diagnostic omitted hash"
|
echo "$output" | grep -qF 'sha256=' || fail "shadow diagnostic omitted hash"
|
||||||
if contains_literal "$output" codex; then
|
if echo "$output" | grep -qF 'codex'; then
|
||||||
fail "shadow diagnostic leaked value"
|
fail "shadow diagnostic leaked value"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -435,9 +321,9 @@ if output=$(run_start "$HOME_COMMAND" coder2 2>&1); then
|
|||||||
fail "arbitrary command override was accepted"
|
fail "arbitrary command override was accepted"
|
||||||
fi
|
fi
|
||||||
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before command rejection"
|
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before command rejection"
|
||||||
contains_literal "$output" 'key=MOSAIC_AGENT_COMMAND' || fail "command diagnostic omitted key"
|
echo "$output" | grep -qF 'key=MOSAIC_AGENT_COMMAND' || fail "command diagnostic omitted key"
|
||||||
contains_literal "$output" 'sha256=' || fail "command diagnostic omitted hash"
|
echo "$output" | grep -qF 'sha256=' || fail "command diagnostic omitted hash"
|
||||||
if contains_literal "$output" "$COMMAND_VALUE"; then
|
if echo "$output" | grep -qF "$COMMAND_VALUE"; then
|
||||||
fail "command diagnostic leaked command value"
|
fail "command diagnostic leaked command value"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -451,7 +337,7 @@ if output=$(run_start "$HOME_PERMS" coder3 2>&1); then
|
|||||||
fail "world-readable local input was accepted"
|
fail "world-readable local input was accepted"
|
||||||
fi
|
fi
|
||||||
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before permissions rejection"
|
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before permissions rejection"
|
||||||
contains_literal "$output" 'code=unsafe-permissions' || fail "permission diagnostic missing"
|
echo "$output" | grep -qF 'code=unsafe-permissions' || fail "permission diagnostic missing"
|
||||||
|
|
||||||
# A unit/holder-like clean bootstrap must yield a pane with trusted HOME and
|
# A unit/holder-like clean bootstrap must yield a pane with trusted HOME and
|
||||||
# computed PATH only. The pane command itself must not carry loader, shell
|
# computed PATH only. The pane command itself must not carry loader, shell
|
||||||
@@ -489,124 +375,27 @@ PATH="$PANE_STALE_PATH" \
|
|||||||
"MOSAIC_TEST_PANE_PID=$$" \
|
"MOSAIC_TEST_PANE_PID=$$" \
|
||||||
"$START" coder-pane-boundary
|
"$START" coder-pane-boundary
|
||||||
pane_args=$(tr '\0' '\n' < "$TMUX_CALLS")
|
pane_args=$(tr '\0' '\n' < "$TMUX_CALLS")
|
||||||
contains_line "$pane_args" "HOME=$PANE_TRUSTED_HOME" || \
|
echo "$pane_args" | grep -qxF "HOME=$PANE_TRUSTED_HOME" || \
|
||||||
fail "pane did not restore trusted HOME"
|
fail "pane did not restore trusted HOME"
|
||||||
contains_literal "$pane_args" "HOME=$PANE_STALE_HOME" && \
|
echo "$pane_args" | grep -qF "HOME=$PANE_STALE_HOME" && \
|
||||||
fail "pane inherited stale HOME"
|
fail "pane inherited stale HOME"
|
||||||
contains_literal "$pane_args" "$PANE_STALE_PATH" && fail "pane inherited stale PATH"
|
echo "$pane_args" | grep -qF "$PANE_STALE_PATH" && fail "pane inherited stale PATH"
|
||||||
for blocked in LD_PRELOAD= BASH_ENV= MOSAIC_UNTRUSTED_SENTINEL=; do
|
for blocked in LD_PRELOAD= BASH_ENV= MOSAIC_UNTRUSTED_SENTINEL=; do
|
||||||
contains_literal "$pane_args" "$blocked" && fail "pane inherited $blocked"
|
echo "$pane_args" | grep -qF "$blocked" && fail "pane inherited $blocked"
|
||||||
done
|
done
|
||||||
|
|
||||||
check_pane_environment_boundary "$TMUX_CALLS" || \
|
after_pane_env=$(printf '%s\n' "$pane_args" | grep -n -m1 -F '/usr/bin/env' | cut -d: -f1)
|
||||||
fail "pane command did not use an adjacent /usr/bin/env -i boundary"
|
[ -n "$after_pane_env" ] || fail "pane command did not use absolute env"
|
||||||
|
printf '%s\n' "$pane_args" | tail -n +"$after_pane_env" | grep -qxF -- '-i' || \
|
||||||
|
fail "pane command did not clear its environment"
|
||||||
pane_environment=$(tr '\0' '\n' < "$HOME_PANE_BOUNDARY/fleet/pane-environment")
|
pane_environment=$(tr '\0' '\n' < "$HOME_PANE_BOUNDARY/fleet/pane-environment")
|
||||||
# Exercise the repository launcher at $START, not the independently installed
|
echo "$pane_environment" | grep -qxF "HOME=$PANE_TRUSTED_HOME" || \
|
||||||
# host copy. Set-compare every declared generated projection entry with the
|
|
||||||
# launched process environment so a newly declared identity cannot be omitted
|
|
||||||
# by a hand-maintained per-variable assertion.
|
|
||||||
declared_generated_environment=$(sort "$HOME_PANE_BOUNDARY/fleet/agents/coder-pane-boundary.env.generated")
|
|
||||||
missing_or_changed_generated_environment=$(comm -23 \
|
|
||||||
<(printf '%s\n' "$declared_generated_environment") \
|
|
||||||
<(printf '%s\n' "$pane_environment" | sort))
|
|
||||||
if [ -n "$missing_or_changed_generated_environment" ]; then
|
|
||||||
missing_or_changed_keys=$(printf '%s\n' "$missing_or_changed_generated_environment" | cut -d= -f1 | paste -sd, -)
|
|
||||||
fail "runtime pane omitted or changed generated environment keys: $missing_or_changed_keys"
|
|
||||||
fi
|
|
||||||
contains_line "$pane_environment" "HOME=$PANE_TRUSTED_HOME" || \
|
|
||||||
fail "runtime pane did not receive trusted HOME"
|
fail "runtime pane did not receive trusted HOME"
|
||||||
contains_literal "$pane_environment" "$PANE_STALE_PATH" && fail "runtime pane received stale PATH"
|
echo "$pane_environment" | grep -qF "$PANE_STALE_PATH" && fail "runtime pane received stale PATH"
|
||||||
for blocked in LD_PRELOAD= BASH_ENV= MOSAIC_UNTRUSTED_SENTINEL=; do
|
for blocked in LD_PRELOAD= BASH_ENV= MOSAIC_UNTRUSTED_SENTINEL=; do
|
||||||
contains_literal "$pane_environment" "$blocked" && fail "runtime pane received $blocked"
|
echo "$pane_environment" | grep -qF "$blocked" && fail "runtime pane received $blocked"
|
||||||
done
|
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() {
|
write_interaction_generated() {
|
||||||
local home="$1"
|
local home="$1"
|
||||||
local agent="$2"
|
local agent="$2"
|
||||||
@@ -616,7 +405,6 @@ write_interaction_generated() {
|
|||||||
chmod 600 "$home/fleet/run/holder-owner"
|
chmod 600 "$home/fleet/run/holder-owner"
|
||||||
cat > "$home/fleet/agents/$agent.env.generated" <<EOF
|
cat > "$home/fleet/agents/$agent.env.generated" <<EOF
|
||||||
MOSAIC_AGENT_NAME=$agent
|
MOSAIC_AGENT_NAME=$agent
|
||||||
MOSAIC_GIT_IDENTITY=$agent
|
|
||||||
MOSAIC_AGENT_CLASS=operator-interaction
|
MOSAIC_AGENT_CLASS=operator-interaction
|
||||||
MOSAIC_AGENT_RUNTIME=pi
|
MOSAIC_AGENT_RUNTIME=pi
|
||||||
MOSAIC_AGENT_MODEL=openai/gpt-5.6-sol
|
MOSAIC_AGENT_MODEL=openai/gpt-5.6-sol
|
||||||
@@ -679,12 +467,8 @@ write_generated "$HOME_NATIVE_STALE" "coder-native-stale"
|
|||||||
write_heartbeat_local "$HOME_NATIVE_STALE" "coder-native-stale"
|
write_heartbeat_local "$HOME_NATIVE_STALE" "coder-native-stale"
|
||||||
STALE_HB="$HOME_NATIVE_STALE/run/coder-native-stale.hb"
|
STALE_HB="$HOME_NATIVE_STALE/run/coder-native-stale.hb"
|
||||||
printf 'ts=native\npid=1\nstatus=busy\nmodel=stale-model\n' > "$STALE_HB"
|
printf 'ts=native\npid=1\nstatus=busy\nmodel=stale-model\n' > "$STALE_HB"
|
||||||
touch -t 200001010000.00 "$STALE_HB.native"
|
touch -d '10 seconds ago' "$STALE_HB.native"
|
||||||
# Hold the sidecar's observation epoch constant: assertion runtime must not age
|
MOSAIC_TEST_PANE_PID=$$ run_start "$HOME_NATIVE_STALE" coder-native-stale
|
||||||
# a fresh-marker mutant into the stale state that this fixture must distinguish.
|
|
||||||
STALE_OBSERVATION_EPOCH=$(date +%s)
|
|
||||||
MOSAIC_TEST_FIXED_EPOCH="$STALE_OBSERVATION_EPOCH" \
|
|
||||||
MOSAIC_TEST_PANE_PID=$$ run_start "$HOME_NATIVE_STALE" coder-native-stale
|
|
||||||
wait_for_sidecar_status "$STALE_HB"
|
wait_for_sidecar_status "$STALE_HB"
|
||||||
|
|
||||||
HOME_NATIVE_ABSENT="$ROOT/native-absent"
|
HOME_NATIVE_ABSENT="$ROOT/native-absent"
|
||||||
@@ -705,22 +489,22 @@ if output=$(run_interaction "$HOME_INTERACTION_MALFORMED" interaction-malformed
|
|||||||
fail "interaction wrapper accepted malformed generated data"
|
fail "interaction wrapper accepted malformed generated data"
|
||||||
fi
|
fi
|
||||||
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before interaction strict-parser rejection"
|
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before interaction strict-parser rejection"
|
||||||
contains_literal "$output" 'code=unknown-key' || fail "interaction did not use shared strict parser first"
|
echo "$output" | grep -qF 'code=unknown-key' || fail "interaction did not use shared strict parser first"
|
||||||
|
|
||||||
# A syntactically valid but policy-incompatible projection reaches the pinned
|
# A syntactically valid but policy-incompatible projection reaches the pinned
|
||||||
# interaction policy check only after strict parsing and never starts tmux.
|
# interaction policy check only after strict parsing and never starts tmux.
|
||||||
: > "$TMUX_CALLS"
|
: > "$TMUX_CALLS"
|
||||||
HOME_INTERACTION_POLICY="$ROOT/interaction-policy"
|
HOME_INTERACTION_POLICY="$ROOT/interaction-policy"
|
||||||
write_interaction_generated "$HOME_INTERACTION_POLICY" "interaction-policy"
|
write_interaction_generated "$HOME_INTERACTION_POLICY" "interaction-policy"
|
||||||
sed -i 's|^MOSAIC_AGENT_RUNTIME=pi$|MOSAIC_AGENT_RUNTIME=codex|' \
|
perl -0pi -e 's/MOSAIC_AGENT_RUNTIME=pi/MOSAIC_AGENT_RUNTIME=codex/' \
|
||||||
"$HOME_INTERACTION_POLICY/fleet/agents/interaction-policy.env.generated"
|
"$HOME_INTERACTION_POLICY/fleet/agents/interaction-policy.env.generated"
|
||||||
if output=$(run_interaction "$HOME_INTERACTION_POLICY" interaction-policy 2>&1); then
|
if output=$(run_interaction "$HOME_INTERACTION_POLICY" interaction-policy 2>&1); then
|
||||||
fail "interaction wrapper accepted a policy-incompatible projection"
|
fail "interaction wrapper accepted a policy-incompatible projection"
|
||||||
fi
|
fi
|
||||||
interaction_policy_args=$(tr '\0' '\n' < "$TMUX_CALLS")
|
interaction_policy_args=$(tr '\0' '\n' < "$TMUX_CALLS")
|
||||||
contains_literal "$interaction_policy_args" new-session && \
|
echo "$interaction_policy_args" | grep -qF 'new-session' && \
|
||||||
fail "interaction pinned-policy rejection created a tmux session"
|
fail "interaction pinned-policy rejection created a tmux session"
|
||||||
contains_literal "$output" 'operator interaction service requires runtime pi' || \
|
echo "$output" | grep -qF 'operator interaction service requires runtime pi' || \
|
||||||
fail "interaction pinned-policy check did not follow strict parsing"
|
fail "interaction pinned-policy check did not follow strict parsing"
|
||||||
|
|
||||||
# #1241. The pane runs `mosaic yolo <runtime>` against PANE_PATH. A binary
|
# #1241. The pane runs `mosaic yolo <runtime>` against PANE_PATH. A binary
|
||||||
@@ -802,10 +586,10 @@ HOME="$HOME_STOP" PATH="$FAKE_BIN:$PATH" MOSAIC_TEST_TMUX_CALLS="$TMUX_CALLS" \
|
|||||||
MOSAIC_TEST_FLEET_OWNER=123e4567-e89b-12d3-a456-426614174000 \
|
MOSAIC_TEST_FLEET_OWNER=123e4567-e89b-12d3-a456-426614174000 \
|
||||||
MOSAIC_HOME="$HOME_STOP" MOSAIC_TMUX_SOCKET=ambient-socket "$START" --stop coder-stop
|
MOSAIC_HOME="$HOME_STOP" MOSAIC_TMUX_SOCKET=ambient-socket "$START" --stop coder-stop
|
||||||
stop_args=$(tr '\0' '\n' < "$TMUX_CALLS")
|
stop_args=$(tr '\0' '\n' < "$TMUX_CALLS")
|
||||||
contains_line "$stop_args" mosaic-test || fail "exact stop did not use the validated generated socket"
|
echo "$stop_args" | grep -qxF 'mosaic-test' || fail "exact stop did not use the validated generated socket"
|
||||||
contains_line "$stop_args" kill-session || fail "exact stop did not request session termination"
|
echo "$stop_args" | grep -qxF 'kill-session' || fail "exact stop did not request session termination"
|
||||||
contains_line "$stop_args" '=coder-stop' || fail "exact stop did not exact-match the generated agent name"
|
echo "$stop_args" | grep -qxF '=coder-stop' || fail "exact stop did not exact-match the generated agent name"
|
||||||
if contains_literal "$stop_args" ambient-socket; then
|
if echo "$stop_args" | grep -qF 'ambient-socket'; then
|
||||||
fail "exact stop trusted an ambient socket"
|
fail "exact stop trusted an ambient socket"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,7 @@
|
|||||||
|
|
||||||
detect_platform() {
|
detect_platform() {
|
||||||
local remote_url
|
local remote_url
|
||||||
# `|| true` is load-bearing under `set -e`: outside a git repo this returns 128 and
|
remote_url=$(git remote get-url origin 2>/dev/null)
|
||||||
# kills the CALLER before the -z check below can run, so the error message that is
|
|
||||||
# already written here was unreachable. Same idiom as get_gitea_repo_args() below.
|
|
||||||
remote_url=$(git remote get-url origin 2>/dev/null) || true
|
|
||||||
|
|
||||||
if [[ -z "$remote_url" ]]; then
|
if [[ -z "$remote_url" ]]; then
|
||||||
echo "error: not a git repository or no origin remote" >&2
|
echo "error: not a git repository or no origin remote" >&2
|
||||||
@@ -42,10 +39,7 @@ detect_platform() {
|
|||||||
|
|
||||||
get_repo_info() {
|
get_repo_info() {
|
||||||
local remote_url
|
local remote_url
|
||||||
# `|| true` is load-bearing under `set -e`: outside a git repo this returns 128 and
|
remote_url=$(git remote get-url origin 2>/dev/null)
|
||||||
# kills the CALLER before the -z check below can run, so the error message that is
|
|
||||||
# already written here was unreachable. Same idiom as get_gitea_repo_args() below.
|
|
||||||
remote_url=$(git remote get-url origin 2>/dev/null) || true
|
|
||||||
|
|
||||||
if [[ -z "$remote_url" ]]; then
|
if [[ -z "$remote_url" ]]; then
|
||||||
echo "error: not a git repository or no origin remote" >&2
|
echo "error: not a git repository or no origin remote" >&2
|
||||||
@@ -246,21 +240,6 @@ PY
|
|||||||
} >&2
|
} >&2
|
||||||
}
|
}
|
||||||
|
|
||||||
# Explain tea's most misleading failure. `user does not exist [uid: 0, name: ]` reads
|
|
||||||
# as a missing account; it almost always means a REVOKED OR STALE TOKEN. `tea login`
|
|
||||||
# keeps its OWN COPY of the token, so rotating the credential store does not update it.
|
|
||||||
# Diagnostic only -- stderr, no control flow, no exit.
|
|
||||||
explain_tea_user_does_not_exist() {
|
|
||||||
cat >&2 <<'MSG'
|
|
||||||
NOTE: `user does not exist [uid: 0, name: ]` from tea usually means a REVOKED OR STALE TOKEN,
|
|
||||||
not a missing account. A `tea login` stores its OWN COPY of the token; rotating the
|
|
||||||
credential store does NOT update it.
|
|
||||||
CHECK: the login's cached copy (`tea login list` -- read the FULL table, never `| head`),
|
|
||||||
then re-register that login against the current token.
|
|
||||||
DO NOT probe capability with a mutating request; a POST is the action, not a check.
|
|
||||||
MSG
|
|
||||||
}
|
|
||||||
|
|
||||||
get_gitea_login_for_host() {
|
get_gitea_login_for_host() {
|
||||||
local host="${1:-}"
|
local host="${1:-}"
|
||||||
local login
|
local login
|
||||||
|
|||||||
@@ -91,32 +91,13 @@ elif [[ "$PLATFORM" == "gitea" ]]; then
|
|||||||
GITEA_LOGIN_NAME=$(get_gitea_login || true)
|
GITEA_LOGIN_NAME=$(get_gitea_login || true)
|
||||||
if [[ -n "$GITEA_LOGIN_NAME" ]]; then
|
if [[ -n "$GITEA_LOGIN_NAME" ]]; then
|
||||||
if [[ -n "$COMMENT" ]]; then
|
if [[ -n "$COMMENT" ]]; then
|
||||||
# `tea issue comment` is NOT a subcommand -- tea 0.11.x lists only
|
tea issue comment "$ISSUE_NUMBER" "$COMMENT" --repo "$OWNER/$REPO" --login "$GITEA_LOGIN_NAME"
|
||||||
# list/create/edit/reopen/close under `tea issue`. Comments are the
|
|
||||||
# TOP-LEVEL `tea comment`, which takes the same --repo/--login flags.
|
|
||||||
# The old call therefore always failed, was unchecked, and the script
|
|
||||||
# closed the issue anyway, losing the record of WHY.
|
|
||||||
#
|
|
||||||
# Use `tea comment` rather than the API helper so the comment and the
|
|
||||||
# close are made by the SAME principal ($GITEA_LOGIN_NAME). Routing the
|
|
||||||
# comment through the token-authenticated helper here would attribute the
|
|
||||||
# comment to the token holder and the close to the tea login -- two
|
|
||||||
# principals for one operation.
|
|
||||||
tea comment "$ISSUE_NUMBER" "$COMMENT" --repo "$OWNER/$REPO" --login "$GITEA_LOGIN_NAME" || {
|
|
||||||
echo "Error: failed to post comment on #$ISSUE_NUMBER -- NOT closing (fail closed)." >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
fi
|
fi
|
||||||
tea issue close "$ISSUE_NUMBER" --repo "$OWNER/$REPO" --login "$GITEA_LOGIN_NAME"
|
tea issue close "$ISSUE_NUMBER" --repo "$OWNER/$REPO" --login "$GITEA_LOGIN_NAME"
|
||||||
else
|
else
|
||||||
echo "No tea login configured for $(get_remote_host); using authenticated Gitea API fallback." >&2
|
echo "No tea login configured for $(get_remote_host); using authenticated Gitea API fallback." >&2
|
||||||
if [[ -n "$COMMENT" ]]; then
|
if [[ -n "$COMMENT" ]]; then
|
||||||
# Fail closed here too: an unchecked comment lets the issue close without its
|
gitea_issue_comment_api
|
||||||
# audit trail, which is the same defect as the tea path above.
|
|
||||||
gitea_issue_comment_api || {
|
|
||||||
echo "Error: failed to post comment on #$ISSUE_NUMBER -- NOT closing (fail closed)." >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
fi
|
fi
|
||||||
gitea_issue_close_api
|
gitea_issue_close_api
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -102,17 +102,9 @@ gitea_resolve_api_for_login() {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
# NO --login: the acting credential is this identity's own token and there
|
GITEA_API_TOKEN=$(get_gitea_token_for_login "$effective_login" "$host") \
|
||||||
# is deliberately no tea-config fallback. get_gitea_token_for_login matches
|
|| GITEA_API_TOKEN=$(get_gitea_token "$host") || {
|
||||||
# by login NAME and performs no authentication check, and with no --login
|
echo "Error: Gitea token not found for login '$effective_login' (comment write/read-back)" >&2
|
||||||
# that name was a HOST GUESS resolving to a shared account. A live shared
|
|
||||||
# token would therefore have authored every seat's comment as that
|
|
||||||
# account, making Gate-16 author-is-not-reviewer unenforceable fleet-wide;
|
|
||||||
# a dead one is only what made the defect visible. get_gitea_token fails
|
|
||||||
# loud on a fleet host when no identity resolves, and that refusal is the
|
|
||||||
# correct outcome, not a case to fall back from.
|
|
||||||
GITEA_API_TOKEN=$(get_gitea_token "$host") || {
|
|
||||||
echo "Error: no Gitea credential resolved for the acting identity on host '$host' (comment write/read-back). Set MOSAIC_GIT_IDENTITY=<agent-id>, or pass --login <name> to use a named tea credential." >&2
|
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
fi
|
fi
|
||||||
@@ -262,32 +254,15 @@ from urllib.parse import urlparse
|
|||||||
|
|
||||||
|
|
||||||
def _origin_and_path(url):
|
def _origin_and_path(url):
|
||||||
# Normalize a URL to (scheme-class, host, distinguishing-port) + comment path.
|
# Normalize a URL to (scheme, host, effective-port) + comment path. The port
|
||||||
#
|
# defaults to the scheme's default (80 http / 443 otherwise) so an implicit
|
||||||
# #991: http and https collapse into ONE scheme class ("web"). A Gitea whose
|
# port and its explicit default form compare equal.
|
||||||
# ROOT_URL is configured http:// returns http:// object URLs even when every
|
|
||||||
# client reaches it over https://, so a scheme-strict comparison rejects the
|
|
||||||
# provider's own correct answer about a write that landed — a deterministic
|
|
||||||
# false negative on every comment posted against such a deployment. The
|
|
||||||
# scheme is also not what this check defends: the forgeries it exists to
|
|
||||||
# catch (look-alike host, decoy path prefix, wrong owner/repo/number) all
|
|
||||||
# vary the HOST or the PATH, both of which stay strict below. Any OTHER
|
|
||||||
# scheme (file:, ftp:, javascript:) remains distinguishing and is rejected.
|
|
||||||
#
|
|
||||||
# Port: an implicit port and its own scheme's default compare equal, so
|
|
||||||
# http://h == https://h. An EXPLICIT non-default port still distinguishes,
|
|
||||||
# because a different port is a different service on the same host.
|
|
||||||
parsed = urlparse(url or "")
|
parsed = urlparse(url or "")
|
||||||
scheme = (parsed.scheme or "").lower()
|
scheme = (parsed.scheme or "").lower()
|
||||||
host = (parsed.hostname or "").lower()
|
host = (parsed.hostname or "").lower()
|
||||||
if scheme in ("http", "https"):
|
default_port = 80 if scheme == "http" else 443
|
||||||
scheme_class = "web"
|
port = parsed.port if parsed.port is not None else default_port
|
||||||
default_port = 80 if scheme == "http" else 443
|
return (scheme, host, port), parsed.path.rstrip("/")
|
||||||
port = None if parsed.port in (None, default_port) else parsed.port
|
|
||||||
else:
|
|
||||||
scheme_class = scheme
|
|
||||||
port = parsed.port
|
|
||||||
return (scheme_class, host, port), parsed.path.rstrip("/")
|
|
||||||
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -343,12 +318,15 @@ if [[ "$PLATFORM" == "github" ]]; then
|
|||||||
gh issue comment "$ISSUE_NUMBER" --body "$COMMENT"
|
gh issue comment "$ISSUE_NUMBER" --body "$COMMENT"
|
||||||
echo "Added comment to GitHub issue #$ISSUE_NUMBER"
|
echo "Added comment to GitHub issue #$ISSUE_NUMBER"
|
||||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||||
# A --login override selects a NAMED tea credential and is the only way to
|
# Resolve the login this comment should be attributed to: the --login
|
||||||
# reach the tea store. With no --login there is deliberately no guess: the
|
# override when given, otherwise the detected default for this repo's host.
|
||||||
# comment is attributed to this identity's own credential, resolved by
|
# A --login override always wins. Otherwise name this repo host's login only
|
||||||
# gitea_resolve_api_for_login. The guess this replaced named a SHARED
|
# as a best effort: the login name merely selects a per-login token, and
|
||||||
# account, selecting an identity the caller never asked to act as.
|
# gitea_resolve_api_for_login falls back to the host credential
|
||||||
|
# (get_gitea_token) when no tea login is named, so the default credential
|
||||||
|
# still resolves even when the host tea has no matching login entry.
|
||||||
EFFECTIVE_LOGIN="$LOGIN_OVERRIDE"
|
EFFECTIVE_LOGIN="$LOGIN_OVERRIDE"
|
||||||
|
[[ -n "$EFFECTIVE_LOGIN" ]] || EFFECTIVE_LOGIN=$(get_gitea_login 2>/dev/null || true)
|
||||||
|
|
||||||
# Bind the REST endpoint + token to the effective login, then derive the
|
# Bind the REST endpoint + token to the effective login, then derive the
|
||||||
# acting identity from that SAME credential (GET /user). The write below and
|
# acting identity from that SAME credential (GET /user). The write below and
|
||||||
|
|||||||
@@ -156,7 +156,6 @@ case "$PLATFORM" in
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
echo "Warning: tea issue create failed, trying Gitea API fallback..." >&2
|
echo "Warning: tea issue create failed, trying Gitea API fallback..." >&2
|
||||||
{ declare -F explain_tea_user_does_not_exist >/dev/null && explain_tea_user_does_not_exist; } || true
|
|
||||||
fi
|
fi
|
||||||
gitea_issue_create_api
|
gitea_issue_create_api
|
||||||
;;
|
;;
|
||||||
|
|||||||
@@ -71,7 +71,6 @@ elif [[ "$PLATFORM" == "gitea" ]]; then
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
echo "Warning: tea issue view failed, trying Gitea API fallback..." >&2
|
echo "Warning: tea issue view failed, trying Gitea API fallback..." >&2
|
||||||
{ declare -F explain_tea_user_does_not_exist >/dev/null && explain_tea_user_does_not_exist; } || true
|
|
||||||
fi
|
fi
|
||||||
gitea_issue_view_api
|
gitea_issue_view_api
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -1,306 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# mosaic-worktree.sh — the only supported way to create and dispose of a git
|
|
||||||
# worktree on a fleet host.
|
|
||||||
#
|
|
||||||
# Why this exists as a helper and not as a rule: the rule already existed, in
|
|
||||||
# the framework's own words ("Big work → /var/tmp"), and 255 GB accumulated in
|
|
||||||
# $HOME across 842 directories anyway. Five placement conventions were live on
|
|
||||||
# one fleet host simultaneously. Every one was a decision an agent had to make,
|
|
||||||
# and a decision an agent has to make is a decision that drifts.
|
|
||||||
#
|
|
||||||
# So this script makes NO placement decision available. The caller supplies a
|
|
||||||
# branch name. Every path is DERIVED:
|
|
||||||
#
|
|
||||||
# main worktree <- git worktree list --porcelain (never cwd, which may
|
|
||||||
# itself already be a worktree)
|
|
||||||
# REPO_NAME <- basename of the main worktree
|
|
||||||
# REPO_PARENT <- dirname of the main worktree
|
|
||||||
# WT_ROOT <- $REPO_PARENT/$REPO_NAME-worktrees
|
|
||||||
# SLUG <- branch with '/' replaced by '-'
|
|
||||||
# WT_PATH <- $WT_ROOT/$SLUG
|
|
||||||
#
|
|
||||||
# The derivation puts the worktree on the same filesystem as the object store
|
|
||||||
# it shares, as a sibling of the repo, under one root per repo. Those are the
|
|
||||||
# properties that make the checkout cheap and — via `git worktree list` —
|
|
||||||
# enumerable, which is the only reason automated cleanup can ever be safe.
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# mosaic-worktree.sh new <branch> [--from <base>] create (branch may exist)
|
|
||||||
# mosaic-worktree.sh path <branch> print derived path, no side effect
|
|
||||||
# mosaic-worktree.sh list this repo's worktrees + state
|
|
||||||
# mosaic-worktree.sh rm <branch> [--force] remove; refuses to lose work
|
|
||||||
# mosaic-worktree.sh gc [--apply] report/remove clean+pushed worktrees
|
|
||||||
#
|
|
||||||
# `rm` and `gc` refuse to delete a worktree with uncommitted changes, with
|
|
||||||
# commits absent from every remote, or holding ignored files that are not of the
|
|
||||||
# well-known regenerable kind (a `.env` is ignored so it is never committed,
|
|
||||||
# which is also why nothing else holds a copy). That check is by EVIDENCE, never
|
|
||||||
# by size or age. --force overrides it and is yours to type deliberately.
|
|
||||||
#
|
|
||||||
# Run from anywhere inside the repo, or pass --repo <path>.
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
die() { printf 'mosaic-worktree: %s\n' "$*" >&2; exit 1; }
|
|
||||||
|
|
||||||
REPO_HINT=""
|
|
||||||
ARGS=()
|
|
||||||
while [ $# -gt 0 ]; do
|
|
||||||
case "$1" in
|
|
||||||
--repo) REPO_HINT="${2:-}"; shift 2 ;;
|
|
||||||
*) ARGS+=("$1"); shift ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
set -- "${ARGS[@]+"${ARGS[@]}"}"
|
|
||||||
|
|
||||||
CMD="${1:-}"
|
|
||||||
[ -n "$CMD" ] || die "no command. Try: new | path | list | rm | gc"
|
|
||||||
shift || true
|
|
||||||
|
|
||||||
# ---- mechanical derivation -------------------------------------------------
|
|
||||||
# The FIRST entry of `git worktree list --porcelain` is always the main
|
|
||||||
# worktree, regardless of which worktree we are standing in. Deriving from cwd
|
|
||||||
# would nest worktrees inside worktrees.
|
|
||||||
resolve_repo() {
|
|
||||||
local start="${REPO_HINT:-$PWD}"
|
|
||||||
git -C "$start" rev-parse --git-dir >/dev/null 2>&1 \
|
|
||||||
|| die "not inside a git repository: $start"
|
|
||||||
# Take the first entry WITHOUT closing the pipe early. `awk ... exit` on the
|
|
||||||
# first match closes the read end while git is still writing, git takes SIGPIPE,
|
|
||||||
# and under `set -euo pipefail` the command substitution returns 141 and this
|
|
||||||
# function aborts SILENTLY — no message, no worktree, and `new` exits 141 while
|
|
||||||
# printing nothing at all.
|
|
||||||
#
|
|
||||||
# Whether it happens depends on how much git still had to write when awk left,
|
|
||||||
# so the failure is a function of REPO SIZE: fine on a repo with three
|
|
||||||
# worktrees, reliably broken on one with seventy. That is backwards — the repos
|
|
||||||
# this helper exists to serve are exactly the ones that accumulated worktrees,
|
|
||||||
# and it silently did nothing on those while working everywhere it was tried.
|
|
||||||
# Measured on a repo with 73 worktrees (10 KB of porcelain): rc=141, no output.
|
|
||||||
#
|
|
||||||
# The file's own comment block below already names this class for `head -200`
|
|
||||||
# and removed that cap for the same reason. The `exit` here is the same defect
|
|
||||||
# in the same file, so the rule is now uniform: nothing in this script closes a
|
|
||||||
# git pipe early. Dropping `exit` costs one pass over a few KB.
|
|
||||||
MAIN_WT="$(git -C "$start" worktree list --porcelain | awk '/^worktree /&&!seen{print substr($0,10); seen=1}')"
|
|
||||||
[ -n "$MAIN_WT" ] || die "could not resolve the main worktree"
|
|
||||||
REPO_NAME="$(basename -- "$MAIN_WT")"
|
|
||||||
REPO_PARENT="$(dirname -- "$MAIN_WT")"
|
|
||||||
WT_ROOT="$REPO_PARENT/$REPO_NAME-worktrees"
|
|
||||||
}
|
|
||||||
|
|
||||||
slugify() { printf '%s' "$1" | tr '/' '-'; }
|
|
||||||
|
|
||||||
derive_path() {
|
|
||||||
local branch="$1"
|
|
||||||
[ -n "$branch" ] || die "branch name required"
|
|
||||||
printf '%s/%s' "$WT_ROOT" "$(slugify "$branch")"
|
|
||||||
}
|
|
||||||
|
|
||||||
# A worktree root under $HOME defeats the entire point: wrong filesystem, and
|
|
||||||
# $HOME is for configuration and state, not work products. Refuse rather than
|
|
||||||
# silently produce the layout we are trying to eliminate.
|
|
||||||
assert_not_home() {
|
|
||||||
local p="$1" home_real repo_real
|
|
||||||
home_real="$(cd "$HOME" && pwd -P)"
|
|
||||||
repo_real="$(cd "$(dirname -- "$p")" 2>/dev/null && pwd -P || dirname -- "$p")"
|
|
||||||
case "$repo_real/" in
|
|
||||||
"$home_real"/*)
|
|
||||||
die "refusing: derived path is under \$HOME ($p).
|
|
||||||
The repo itself lives under \$HOME, so its worktrees would too. Move the repo
|
|
||||||
to a work filesystem (e.g. /src/$REPO_NAME) and re-run. \$HOME holds
|
|
||||||
configuration, credentials, state and caches — not checkouts." ;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
# ---- work-loss evidence ----------------------------------------------------
|
|
||||||
# Two independent questions, both answered from git, neither from size or age:
|
|
||||||
# dirty — anything uncommitted in the tree
|
|
||||||
# unpushed — commits reachable from HEAD that no remote ref contains
|
|
||||||
# precious — IGNORED files git will not mention and will not miss
|
|
||||||
#
|
|
||||||
# The third question is not obvious and was missed on the first pass. An
|
|
||||||
# independent reviewer demonstrated it in four commands: a pushed, clean
|
|
||||||
# worktree whose .gitignore covers `*.secret`, holding one `local.secret`.
|
|
||||||
# `git status --porcelain` is empty, `rev-list --count HEAD --not --remotes` is
|
|
||||||
# 0 — the evidence reads SAFE — and `git worktree remove` deletes the file. The
|
|
||||||
# same shape covers `.env`, credentials, scratch notes, downloaded fixtures:
|
|
||||||
# precisely the files that are ignored BECAUSE they must not be committed, which
|
|
||||||
# is also why nothing else is holding a copy.
|
|
||||||
#
|
|
||||||
# So ignored files count as work unless they are the well-known regenerable
|
|
||||||
# kind. Getting that set wrong is asymmetric: an over-broad list preserves a
|
|
||||||
# worktree that could have been reclaimed (cheap, visible, fixable by --force),
|
|
||||||
# an over-narrow one deletes the only copy of a secret (silent, permanent).
|
|
||||||
# The list stays short and conservative for that reason.
|
|
||||||
DISPOSABLE_RE='(^|/)(node_modules|\.venv|venv|__pycache__|\.mypy_cache|\.pytest_cache|\.ruff_cache|\.turbo|\.cache|\.parcel-cache|\.gradle|dist|build|out|target|coverage|\.next|\.nuxt|\.svelte-kit)(/|$)|\.(pyc|pyo|o|class)$'
|
|
||||||
|
|
||||||
# These three run under `set -euo pipefail` inside command substitution, which
|
|
||||||
# makes any nonzero exit ANYWHERE in the pipeline abort the calling function
|
|
||||||
# silently. Two ways that bites, one of which shipped:
|
|
||||||
#
|
|
||||||
# * `grep -v` exits 1 when it filters everything out. A worktree whose only
|
|
||||||
# ignored entry is `node_modules/` is exactly the SAFE case, and it made
|
|
||||||
# `rm` exit 1 with no message and no removal — found by review.
|
|
||||||
# * `head -200` closes the pipe, SIGPIPEs the producer, and turns a worktree
|
|
||||||
# with 201 dirty files into the same silent abort. Not reported; it is the
|
|
||||||
# same defect one step upstream, so the cap is gone. Counting is cheap;
|
|
||||||
# the cap only ever protected output that is now never printed.
|
|
||||||
#
|
|
||||||
# Every one of them therefore ends in a total, and every stage that can
|
|
||||||
# legitimately exit nonzero says so explicitly.
|
|
||||||
wt_dirty() {
|
|
||||||
local out
|
|
||||||
out="$(git -C "$1" status --porcelain 2>/dev/null || true)"
|
|
||||||
if [ -n "$out" ]; then printf '%s\n' "$out" | wc -l; else printf '0'; fi
|
|
||||||
}
|
|
||||||
|
|
||||||
wt_unpushed() { git -C "$1" rev-list --count HEAD --not --remotes 2>/dev/null || printf '?'; }
|
|
||||||
|
|
||||||
# Default --ignored (not =matching) so a 40k-file node_modules collapses to one
|
|
||||||
# directory entry instead of being enumerated and then discarded.
|
|
||||||
wt_precious() {
|
|
||||||
local ignored
|
|
||||||
ignored="$(git -C "$1" status --porcelain --ignored 2>/dev/null \
|
|
||||||
| awk '/^!! /{print substr($0,4)}' || true)"
|
|
||||||
[ -n "$ignored" ] || { printf '0'; return 0; }
|
|
||||||
printf '%s\n' "$ignored" | grep -Ecv "$DISPOSABLE_RE" || true
|
|
||||||
}
|
|
||||||
|
|
||||||
wt_state() {
|
|
||||||
local wt="$1" d u p
|
|
||||||
d="$(wt_dirty "$wt")"; u="$(wt_unpushed "$wt")"; p="$(wt_precious "$wt")"
|
|
||||||
if [ "$d" -eq 0 ] && [ "$u" = "0" ] && [ "$p" -eq 0 ]; then
|
|
||||||
printf 'SAFE\tclean; 0 unpushed; no ignored files worth keeping'
|
|
||||||
else
|
|
||||||
printf 'PRESERVE\t%s uncommitted; %s unpushed; %s ignored-but-not-disposable' "$d" "$u" "$p"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# ---- commands --------------------------------------------------------------
|
|
||||||
cmd_path() { resolve_repo; derive_path "${1:-}"; echo; }
|
|
||||||
|
|
||||||
cmd_new() {
|
|
||||||
local branch="${1:-}" base=""
|
|
||||||
shift || true
|
|
||||||
while [ $# -gt 0 ]; do
|
|
||||||
case "$1" in --from) base="${2:-}"; shift 2 ;; *) die "unknown flag: $1" ;; esac
|
|
||||||
done
|
|
||||||
[ -n "$branch" ] || die "usage: mosaic-worktree.sh new <branch> [--from <base>]"
|
|
||||||
|
|
||||||
resolve_repo
|
|
||||||
local path; path="$(derive_path "$branch")"
|
|
||||||
assert_not_home "$path"
|
|
||||||
|
|
||||||
if [ -e "$path" ]; then
|
|
||||||
echo "exists: $path"
|
|
||||||
echo "(already checked out — reuse it, or 'rm' it first)"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
mkdir -p "$WT_ROOT"
|
|
||||||
|
|
||||||
# Existing branch -> check it out. New branch -> create from base (default:
|
|
||||||
# the remote's default branch if resolvable, else current HEAD).
|
|
||||||
if git -C "$MAIN_WT" show-ref --verify --quiet "refs/heads/$branch" \
|
|
||||||
|| git -C "$MAIN_WT" show-ref --verify --quiet "refs/remotes/origin/$branch"; then
|
|
||||||
git -C "$MAIN_WT" worktree add "$path" "$branch"
|
|
||||||
else
|
|
||||||
if [ -z "$base" ]; then
|
|
||||||
base="$(git -C "$MAIN_WT" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null || true)"
|
|
||||||
[ -n "$base" ] || base="HEAD"
|
|
||||||
fi
|
|
||||||
git -C "$MAIN_WT" worktree add -b "$branch" "$path" "$base"
|
|
||||||
fi
|
|
||||||
|
|
||||||
cat <<EOF
|
|
||||||
|
|
||||||
worktree: $path
|
|
||||||
branch: $branch
|
|
||||||
|
|
||||||
Removal is part of this task, not a later chore. When the work is pushed:
|
|
||||||
mosaic-worktree.sh rm $branch
|
|
||||||
EOF
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd_list() {
|
|
||||||
resolve_repo
|
|
||||||
printf 'repo: %s\nroot: %s\n\n' "$MAIN_WT" "$WT_ROOT"
|
|
||||||
git -C "$MAIN_WT" worktree list --porcelain \
|
|
||||||
| awk '/^worktree /{print substr($0,10)}' \
|
|
||||||
| while read -r wt; do
|
|
||||||
[ "$wt" = "$MAIN_WT" ] && { printf '%-10s %s (main)\n' "-" "$wt"; continue; }
|
|
||||||
printf '%-10s %s\t%s\n' "$(wt_state "$wt" | cut -f1)" "$wt" "$(wt_state "$wt" | cut -f2)"
|
|
||||||
done
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd_rm() {
|
|
||||||
local branch="${1:-}" force=0
|
|
||||||
shift || true
|
|
||||||
while [ $# -gt 0 ]; do
|
|
||||||
case "$1" in --force) force=1; shift ;; *) die "unknown flag: $1" ;; esac
|
|
||||||
done
|
|
||||||
[ -n "$branch" ] || die "usage: mosaic-worktree.sh rm <branch> [--force]"
|
|
||||||
|
|
||||||
resolve_repo
|
|
||||||
local path; path="$(derive_path "$branch")"
|
|
||||||
[ -d "$path" ] || die "no worktree at $path"
|
|
||||||
|
|
||||||
local d u p
|
|
||||||
d="$(wt_dirty "$path")"; u="$(wt_unpushed "$path")"; p="$(wt_precious "$path")"
|
|
||||||
if [ "$force" -eq 0 ] && { [ "$d" -ne 0 ] || [ "$u" != "0" ] || [ "$p" -ne 0 ]; }; then
|
|
||||||
die "refusing to remove $path
|
|
||||||
uncommitted files: $d
|
|
||||||
unpushed commits: $u
|
|
||||||
ignored, not disposable: $p
|
|
||||||
Commit and push first — that is the contract. Ignored files are counted because
|
|
||||||
git will neither report them nor miss them: a .env or a *.secret is ignored
|
|
||||||
precisely so it is never committed, which is also why nothing else holds a copy.
|
|
||||||
List them with: git -C $path status --porcelain --ignored | grep '^!!'
|
|
||||||
If this work is genuinely disposable, re-run with --force."
|
|
||||||
fi
|
|
||||||
|
|
||||||
# NB: ${force:+--force} would expand for force=0 too ("0" is non-empty).
|
|
||||||
if [ "$force" -eq 1 ]; then
|
|
||||||
git -C "$MAIN_WT" worktree remove --force "$path"
|
|
||||||
else
|
|
||||||
git -C "$MAIN_WT" worktree remove "$path"
|
|
||||||
fi
|
|
||||||
git -C "$MAIN_WT" worktree prune
|
|
||||||
echo "removed: $path"
|
|
||||||
rmdir "$WT_ROOT" 2>/dev/null || true
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd_gc() {
|
|
||||||
local apply=0
|
|
||||||
[ "${1:-}" = "--apply" ] && apply=1
|
|
||||||
resolve_repo
|
|
||||||
git -C "$MAIN_WT" worktree prune
|
|
||||||
git -C "$MAIN_WT" worktree list --porcelain \
|
|
||||||
| awk '/^worktree /{print substr($0,10)}' \
|
|
||||||
| while read -r wt; do
|
|
||||||
[ "$wt" = "$MAIN_WT" ] && continue
|
|
||||||
local_state="$(wt_state "$wt")"
|
|
||||||
case "$local_state" in
|
|
||||||
SAFE*)
|
|
||||||
if [ "$apply" -eq 1 ]; then
|
|
||||||
git -C "$MAIN_WT" worktree remove "$wt" && echo "removed: $wt"
|
|
||||||
else
|
|
||||||
echo "reclaimable (clean + fully pushed): $wt"
|
|
||||||
fi ;;
|
|
||||||
*) echo "preserved: $wt [$(printf '%s' "$local_state" | cut -f2)]" ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
git -C "$MAIN_WT" worktree prune
|
|
||||||
[ "$apply" -eq 1 ] || echo $'\n(report only — re-run with --apply to remove the reclaimable ones)'
|
|
||||||
}
|
|
||||||
|
|
||||||
case "$CMD" in
|
|
||||||
new) cmd_new "$@" ;;
|
|
||||||
path) cmd_path "$@" ;;
|
|
||||||
list) cmd_list "$@" ;;
|
|
||||||
rm) cmd_rm "$@" ;;
|
|
||||||
gc) cmd_gc "$@" ;;
|
|
||||||
-h|--help|help) sed -n '2,40p' "$0" | sed 's/^# \{0,1\}//' ;;
|
|
||||||
*) die "unknown command: $CMD (new | path | list | rm | gc)" ;;
|
|
||||||
esac
|
|
||||||
@@ -84,7 +84,7 @@ cp "$TARGET" "$BAK"
|
|||||||
export MOSAIC_TEST_WORK_DIR="$WORK/.work"
|
export MOSAIC_TEST_WORK_DIR="$WORK/.work"
|
||||||
|
|
||||||
# --- where the prose lives: usage() { ... EOF ---------------------------------
|
# --- where the prose lives: usage() { ... EOF ---------------------------------
|
||||||
PROSE_LO="$(grep -n -m1 '^usage() {' "$BAK" | cut -d: -f1)"
|
PROSE_LO="$(grep -n '^usage() {' "$BAK" | head -1 | cut -d: -f1)"
|
||||||
PROSE_HI="$(awk -v lo="$PROSE_LO" 'NR > lo && /^EOF$/ { print NR; exit }' "$BAK")"
|
PROSE_HI="$(awk -v lo="$PROSE_LO" 'NR > lo && /^EOF$/ { print NR; exit }' "$BAK")"
|
||||||
if [[ -z "$PROSE_LO" || -z "$PROSE_HI" ]]; then
|
if [[ -z "$PROSE_LO" || -z "$PROSE_HI" ]]; then
|
||||||
echo "!! cannot locate the usage() heredoc -- the prose guard would be inert; refusing" >&2
|
echo "!! cannot locate the usage() heredoc -- the prose guard would be inert; refusing" >&2
|
||||||
|
|||||||
@@ -219,7 +219,6 @@ case "$PLATFORM" in
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
echo "Warning: tea pr create failed, trying Gitea API fallback..." >&2
|
echo "Warning: tea pr create failed, trying Gitea API fallback..." >&2
|
||||||
{ declare -F explain_tea_user_does_not_exist >/dev/null && explain_tea_user_does_not_exist; } || true
|
|
||||||
gitea_pr_create_api
|
gitea_pr_create_api
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
|
|||||||
@@ -1,185 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# pr-edit.sh - Edit a pull request on GitHub or Gitea
|
|
||||||
# Usage: pr-edit.sh -n <pr_number> [-t <title>] [-b <body>] [-B <base>] [--draft|--ready] [--login <name>] [-r owner/repo] [-H host]
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
# shellcheck source=detect-platform.sh
|
|
||||||
source "$SCRIPT_DIR/detect-platform.sh"
|
|
||||||
|
|
||||||
PR_NUMBER=""
|
|
||||||
TITLE=""
|
|
||||||
BODY=""
|
|
||||||
BASE_BRANCH=""
|
|
||||||
DRAFT_MODE=""
|
|
||||||
LOGIN_OVERRIDE=""
|
|
||||||
REPO_OVERRIDE=""
|
|
||||||
HOST_OVERRIDE=""
|
|
||||||
AUTH_CONFIG=""
|
|
||||||
|
|
||||||
cleanup() {
|
|
||||||
[[ -z "$AUTH_CONFIG" ]] || rm -f -- "$AUTH_CONFIG"
|
|
||||||
}
|
|
||||||
terminate() {
|
|
||||||
local signal="$1"
|
|
||||||
trap - "$signal"
|
|
||||||
cleanup
|
|
||||||
kill -s "$signal" "$$"
|
|
||||||
}
|
|
||||||
trap cleanup EXIT
|
|
||||||
trap 'terminate HUP' HUP
|
|
||||||
trap 'terminate INT' INT
|
|
||||||
trap 'terminate TERM' TERM
|
|
||||||
|
|
||||||
usage() {
|
|
||||||
cat <<EOF
|
|
||||||
Usage: $(basename "$0") [OPTIONS]
|
|
||||||
|
|
||||||
Edit a pull request on the current repository (Gitea or GitHub).
|
|
||||||
|
|
||||||
Options:
|
|
||||||
-n, --number NUMBER Pull request number (required)
|
|
||||||
-t, --title TITLE New title
|
|
||||||
-b, --body BODY New body/description
|
|
||||||
-B, --base BRANCH New base branch
|
|
||||||
--draft Mark the pull request as draft
|
|
||||||
--ready Mark the pull request ready for review
|
|
||||||
-l, --login NAME Gitea login (must authenticate as MOSAIC_GIT_IDENTITY)
|
|
||||||
-r, --repo OWNER/REPO Explicit target repository
|
|
||||||
-H, --host HOST Explicit Gitea host (required with --repo off-host)
|
|
||||||
-h, --help Show this help message
|
|
||||||
EOF
|
|
||||||
exit "${1:-1}"
|
|
||||||
}
|
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
|
||||||
case "$1" in
|
|
||||||
-n|--number) PR_NUMBER="${2:-}"; shift 2 ;;
|
|
||||||
-t|--title) TITLE="${2:-}"; shift 2 ;;
|
|
||||||
-b|--body) BODY="${2:-}"; shift 2 ;;
|
|
||||||
-B|--base) BASE_BRANCH="${2:-}"; shift 2 ;;
|
|
||||||
--draft)
|
|
||||||
[[ "$DRAFT_MODE" != "ready" ]] || { echo "Error: --draft and --ready are mutually exclusive" >&2; exit 1; }
|
|
||||||
DRAFT_MODE="draft"; shift ;;
|
|
||||||
--ready)
|
|
||||||
[[ "$DRAFT_MODE" != "draft" ]] || { echo "Error: --draft and --ready are mutually exclusive" >&2; exit 1; }
|
|
||||||
DRAFT_MODE="ready"; shift ;;
|
|
||||||
-l|--login) LOGIN_OVERRIDE="${2:-}"; shift 2 ;;
|
|
||||||
-r|--repo) REPO_OVERRIDE="${2:-}"; shift 2 ;;
|
|
||||||
-H|--host) HOST_OVERRIDE="${2:-}"; shift 2 ;;
|
|
||||||
-h|--help) usage 0 ;;
|
|
||||||
*) echo "Unknown option: $1" >&2; usage ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
[[ -n "$PR_NUMBER" ]] || { echo "Error: Pull request number is required (-n)" >&2; exit 1; }
|
|
||||||
[[ "$PR_NUMBER" =~ ^[1-9][0-9]*$ ]] || { echo "Error: Pull request number must be a positive integer" >&2; exit 1; }
|
|
||||||
if [[ -z "$TITLE" && -z "$BODY" && -z "$BASE_BRANCH" && -z "$DRAFT_MODE" ]]; then
|
|
||||||
echo "Error: At least one edit option is required" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
[[ -z "$REPO_OVERRIDE" || "$REPO_OVERRIDE" =~ ^[^/[:space:]]+/[^/[:space:]]+$ ]] || {
|
|
||||||
echo "Error: --repo must be OWNER/REPO" >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
if [[ -n "$HOST_OVERRIDE" || -n "$REPO_OVERRIDE" ]]; then
|
|
||||||
PLATFORM="gitea"
|
|
||||||
else
|
|
||||||
PLATFORM=$(detect_platform)
|
|
||||||
fi
|
|
||||||
|
|
||||||
case "$PLATFORM" in
|
|
||||||
github)
|
|
||||||
[[ -z "$LOGIN_OVERRIDE" ]] || { echo "Error: --login is only valid for Gitea" >&2; exit 1; }
|
|
||||||
if [[ -n "$TITLE" || -n "$BODY" || -n "$BASE_BRANCH" ]]; then
|
|
||||||
CMD=(gh pr edit "$PR_NUMBER")
|
|
||||||
[[ -n "$TITLE" ]] && CMD+=(--title "$TITLE")
|
|
||||||
[[ -n "$BODY" ]] && CMD+=(--body "$BODY")
|
|
||||||
[[ -n "$BASE_BRANCH" ]] && CMD+=(--base "$BASE_BRANCH")
|
|
||||||
"${CMD[@]}"
|
|
||||||
fi
|
|
||||||
if [[ "$DRAFT_MODE" == "draft" ]]; then
|
|
||||||
gh pr ready "$PR_NUMBER" --undo
|
|
||||||
elif [[ "$DRAFT_MODE" == "ready" ]]; then
|
|
||||||
gh pr ready "$PR_NUMBER"
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
gitea)
|
|
||||||
IDENTITY="${MOSAIC_GIT_IDENTITY:-}"
|
|
||||||
[[ -n "$IDENTITY" ]] || {
|
|
||||||
echo "Error: MOSAIC_GIT_IDENTITY is required for a mutating Gitea operation" >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
HOST="${HOST_OVERRIDE:-}"
|
|
||||||
if [[ -z "$HOST" ]]; then
|
|
||||||
HOST=$(get_remote_host) || {
|
|
||||||
echo "Error: Could not resolve Gitea host; pass --host with --repo" >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
fi
|
|
||||||
HOST="${HOST#http://}"; HOST="${HOST#https://}"; HOST="${HOST%%/*}"
|
|
||||||
REPO_SLUG="${REPO_OVERRIDE:-}"
|
|
||||||
if [[ -z "$REPO_SLUG" ]]; then
|
|
||||||
REPO_SLUG=$(get_repo_slug) || { echo "Error: Could not resolve Gitea repo slug from remote" >&2; exit 1; }
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -n "$LOGIN_OVERRIDE" ]]; then
|
|
||||||
GITEA_LOGIN_NAME="$LOGIN_OVERRIDE"
|
|
||||||
elif [[ -n "${GITEA_LOGIN:-}" ]]; then
|
|
||||||
GITEA_LOGIN_NAME="$GITEA_LOGIN"
|
|
||||||
else
|
|
||||||
echo "Error: --login (or GITEA_LOGIN) is required; refusing host-first login selection" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
TOKEN=$(get_gitea_token_for_login "$GITEA_LOGIN_NAME" "$HOST") || {
|
|
||||||
echo "Error: login '$GITEA_LOGIN_NAME' is not configured for target host '$HOST'" >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
AUTH_CONFIG=$(gitea_write_auth_config "$TOKEN") || {
|
|
||||||
echo "Error: could not stage private Gitea authentication" >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
unset TOKEN
|
|
||||||
|
|
||||||
API_BASE="https://${HOST}/api/v1"
|
|
||||||
# Resolve identity through the SAME private curl config used for the
|
|
||||||
# mutation. Tea login names are globally scoped and can be duplicated
|
|
||||||
# across hosts; a separate `tea api --login NAME` could validate another
|
|
||||||
# credential than this host-bound token.
|
|
||||||
AUTHENTICATED_USER=$(curl -fsS --config "$AUTH_CONFIG" -H "User-Agent: mosaic-pr-edit" "$API_BASE/user" \
|
|
||||||
| python3 -c 'import json,sys; value=json.load(sys.stdin).get("login"); print(value) if isinstance(value,str) and value else sys.exit(1)') || {
|
|
||||||
echo "Error: could not authenticate the host-bound credential for '$GITEA_LOGIN_NAME'" >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
[[ "$AUTHENTICATED_USER" == "$IDENTITY" ]] || {
|
|
||||||
echo "Error: host-bound credential authenticates as '$AUTHENTICATED_USER', not MOSAIC_GIT_IDENTITY '$IDENTITY'" >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
REPO_API="$API_BASE/repos/${REPO_SLUG}"
|
|
||||||
curl -fsS --config "$AUTH_CONFIG" -H "User-Agent: mosaic-pr-edit" "$REPO_API" >/dev/null || {
|
|
||||||
echo "Error: target repository preflight failed for https://${HOST}/${REPO_SLUG}" >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
PAYLOAD=$(TITLE="$TITLE" BODY="$BODY" BASE_BRANCH="$BASE_BRANCH" DRAFT_MODE="$DRAFT_MODE" python3 - <<'PY'
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
payload = {}
|
|
||||||
if os.environ["TITLE"]: payload["title"] = os.environ["TITLE"]
|
|
||||||
if os.environ["BODY"]: payload["body"] = os.environ["BODY"]
|
|
||||||
if os.environ["BASE_BRANCH"]: payload["base"] = os.environ["BASE_BRANCH"]
|
|
||||||
if os.environ["DRAFT_MODE"]: payload["draft"] = os.environ["DRAFT_MODE"] == "draft"
|
|
||||||
print(json.dumps(payload))
|
|
||||||
PY
|
|
||||||
)
|
|
||||||
curl -fsS --config "$AUTH_CONFIG" -X PATCH \
|
|
||||||
-H "User-Agent: mosaic-pr-edit" -H "Content-Type: application/json" \
|
|
||||||
-d "$PAYLOAD" "$REPO_API/pulls/${PR_NUMBER}"
|
|
||||||
echo "Updated Gitea pull request #$PR_NUMBER as '$AUTHENTICATED_USER'" >&2
|
|
||||||
;;
|
|
||||||
*) echo "Error: Could not detect git platform" >&2; exit 1 ;;
|
|
||||||
esac
|
|
||||||
@@ -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_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_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())')"
|
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_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())')"
|
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
|
if [[ "$BASE_BRANCH" != "main" && "$BASE_BRANCH" != "next" ]]; then
|
||||||
@@ -145,19 +144,10 @@ if [[ -n "$EXPECT_HEAD" && "$HEAD_SHA" != "$EXPECT_HEAD" ]]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "$DRY_RUN" != true ]]; then
|
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" \
|
"$SCRIPT_DIR/ci-queue-wait.sh" \
|
||||||
--purpose merge \
|
--purpose merge \
|
||||||
-B "$HEAD_BRANCH" \
|
-B "$HEAD_BRANCH" \
|
||||||
-R "$BASE_REPO" \
|
-R "$HEAD_REPO" \
|
||||||
--sha "$HEAD_SHA" \
|
--sha "$HEAD_SHA" \
|
||||||
-t "${MOSAIC_CI_QUEUE_TIMEOUT_SEC:-900}" \
|
-t "${MOSAIC_CI_QUEUE_TIMEOUT_SEC:-900}" \
|
||||||
-i "${MOSAIC_CI_QUEUE_POLL_SEC:-15}"
|
-i "${MOSAIC_CI_QUEUE_POLL_SEC:-15}"
|
||||||
|
|||||||
@@ -209,10 +209,6 @@ base_ref = first_non_empty(
|
|||||||
data.get('base_ref'),
|
data.get('base_ref'),
|
||||||
data.get('base_label'),
|
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:
|
if not head_ref or not base_ref:
|
||||||
available = ', '.join(sorted(data.keys()))
|
available = ', '.join(sorted(data.keys()))
|
||||||
@@ -233,7 +229,6 @@ normalized = {
|
|||||||
'headRefOid': head_sha,
|
'headRefOid': head_sha,
|
||||||
'headRepository': head_repo,
|
'headRepository': head_repo,
|
||||||
'baseRefName': base_ref,
|
'baseRefName': base_ref,
|
||||||
'baseRepository': base_repo,
|
|
||||||
'labels': [l.get('name', '') for l in data.get('labels', []) if isinstance(l, dict)],
|
'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)],
|
'assignees': [a.get('login', '') for a in data.get('assignees', []) if isinstance(a, dict)],
|
||||||
'milestone': nested(data, 'milestone', 'title') or '',
|
'milestone': nested(data, 'milestone', 'title') or '',
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
# concurrent record cannot masquerade as this write and a no-op fails closed.
|
# concurrent record cannot masquerade as this write and a no-op fails closed.
|
||||||
#
|
#
|
||||||
# --login override: the default login is resolved from the local tea login list
|
# --login override: the default login is resolved from the local tea login list
|
||||||
# for this repo's host from the acting identity's own credential. Pass --login <name> to
|
# for this repo's host (get_gitea_login_for_host). Pass --login <name> to
|
||||||
# override it for this invocation only. The REST write, the /user identity read,
|
# override it for this invocation only. The REST write, the /user identity read,
|
||||||
# and every read-back are ALL performed with the token of the EFFECTIVE login,
|
# and every read-back are ALL performed with the token of the EFFECTIVE login,
|
||||||
# so the write and its verification bind to the same identity.
|
# so the write and its verification bind to the same identity.
|
||||||
@@ -243,35 +243,15 @@ from urllib.parse import urlparse
|
|||||||
|
|
||||||
|
|
||||||
def _origin_and_path(url):
|
def _origin_and_path(url):
|
||||||
# Normalize a URL to (scheme-class, host, distinguishing-port) + comment path.
|
# Normalize a URL to (scheme, host, effective-port) + comment path. The port
|
||||||
#
|
# defaults to the scheme's default (80 http / 443 otherwise) so an implicit
|
||||||
# #991: http and https collapse into ONE scheme class ("web"). A Gitea whose
|
# port and its explicit default form compare equal.
|
||||||
# ROOT_URL is configured http:// returns http:// object URLs even when every
|
|
||||||
# client reaches it over https://, so a scheme-strict comparison rejects the
|
|
||||||
# provider's own correct answer about a comment that landed — a deterministic
|
|
||||||
# false negative on EVERY review comment posted against such a deployment.
|
|
||||||
# That matters more here than anywhere else: on a host where no seat can
|
|
||||||
# create a review OBJECT, the comment-form review record this path produces
|
|
||||||
# is the only gate-16 evidence available, and this check refuses all of it.
|
|
||||||
# The scheme is also not what the check defends: the forgeries it exists to
|
|
||||||
# catch (look-alike host, decoy path prefix, wrong owner/repo/kind/number)
|
|
||||||
# all vary the HOST or the PATH, both of which stay strict below. Any OTHER
|
|
||||||
# scheme (file:, ftp:, javascript:) remains distinguishing and is rejected.
|
|
||||||
#
|
|
||||||
# Port: an implicit port and its own scheme's default compare equal, so
|
|
||||||
# http://h == https://h. An EXPLICIT non-default port still distinguishes,
|
|
||||||
# because a different port is a different service on the same host.
|
|
||||||
parsed = urlparse(url or "")
|
parsed = urlparse(url or "")
|
||||||
scheme = (parsed.scheme or "").lower()
|
scheme = (parsed.scheme or "").lower()
|
||||||
host = (parsed.hostname or "").lower()
|
host = (parsed.hostname or "").lower()
|
||||||
if scheme in ("http", "https"):
|
default_port = 80 if scheme == "http" else 443
|
||||||
scheme_class = "web"
|
port = parsed.port if parsed.port is not None else default_port
|
||||||
default_port = 80 if scheme == "http" else 443
|
return (scheme, host, port), parsed.path.rstrip("/")
|
||||||
port = None if parsed.port in (None, default_port) else parsed.port
|
|
||||||
else:
|
|
||||||
scheme_class = scheme
|
|
||||||
port = parsed.port
|
|
||||||
return (scheme_class, host, port), parsed.path.rstrip("/")
|
|
||||||
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -372,17 +352,9 @@ gitea_resolve_api_for_login() {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
# NO --login: the acting credential is this identity's own token and there
|
GITEA_API_TOKEN=$(get_gitea_token_for_login "$effective_login" "$host") \
|
||||||
# is deliberately no tea-config fallback. get_gitea_token_for_login matches
|
|| GITEA_API_TOKEN=$(get_gitea_token "$host") || {
|
||||||
# by login NAME and performs no authentication check, and with no --login
|
echo "Error: Gitea token not found for login '$effective_login' (review write/read-back)" >&2
|
||||||
# that name was a HOST GUESS resolving to a shared account. A live shared
|
|
||||||
# token would therefore have authored every seat's review as that
|
|
||||||
# account, making Gate-16 author-is-not-reviewer unenforceable fleet-wide;
|
|
||||||
# a dead one is only what made the defect visible. get_gitea_token fails
|
|
||||||
# loud on a fleet host when no identity resolves, and that refusal is the
|
|
||||||
# correct outcome, not a case to fall back from.
|
|
||||||
GITEA_API_TOKEN=$(get_gitea_token "$host") || {
|
|
||||||
echo "Error: no Gitea credential resolved for the acting identity on host '$host' (review write/read-back). Set MOSAIC_GIT_IDENTITY=<agent-id>, or pass --login <name> to use a named tea credential." >&2
|
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
fi
|
fi
|
||||||
@@ -706,7 +678,7 @@ if [[ "$PLATFORM" == "github" ]]; then
|
|||||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||||
case $ACTION in
|
case $ACTION in
|
||||||
approve)
|
approve)
|
||||||
# Best-effort host for credential resolution only (gitea_resolve_api_for_login
|
# Best-effort host for the tea-login GUESS only (gitea_resolve_api_for_login
|
||||||
# below re-derives the real host from HOST_OVERRIDE/remote independently and
|
# below re-derives the real host from HOST_OVERRIDE/remote independently and
|
||||||
# is authoritative). Prefer an explicit -H/--host; otherwise best-effort
|
# is authoritative). Prefer an explicit -H/--host; otherwise best-effort
|
||||||
# git-remote inference, tolerating its ABSENCE (a bare `get_remote_host` here
|
# git-remote inference, tolerating its ABSENCE (a bare `get_remote_host` here
|
||||||
@@ -714,13 +686,15 @@ elif [[ "$PLATFORM" == "gitea" ]]; then
|
|||||||
# SILENTLY — exit 1, zero output — even though -r/-H are exactly the flags
|
# SILENTLY — exit 1, zero output — even though -r/-H are exactly the flags
|
||||||
# that support running with no usable origin at all).
|
# that support running with no usable origin at all).
|
||||||
host="${HOST_OVERRIDE:-$(get_remote_host 2>/dev/null || true)}"
|
host="${HOST_OVERRIDE:-$(get_remote_host 2>/dev/null || true)}"
|
||||||
# A --login override selects a NAMED tea credential and is the only
|
# A --login override always wins. Otherwise name this host's login
|
||||||
# way to reach the tea store. With no --login there is deliberately no
|
# only as a best effort: the login name merely selects a per-login
|
||||||
# guess: gitea_resolve_api_for_login resolves this identity's own token.
|
# token, and gitea_resolve_api_for_login falls back to the host
|
||||||
# The guess this replaced named a SHARED account, selecting an identity
|
# credential (get_gitea_token) when no tea login is named — so a host
|
||||||
# the caller never asked to act as. The single resolved token is then
|
# tea's login list need not enumerate exotic (e.g. ported) hosts for
|
||||||
# used for the write, the /user identity, and the read-back.
|
# the default credential to resolve. The single resolved token is
|
||||||
|
# then used for the write, the /user identity, and the read-back.
|
||||||
EFFECTIVE_LOGIN="$LOGIN_OVERRIDE"
|
EFFECTIVE_LOGIN="$LOGIN_OVERRIDE"
|
||||||
|
[[ -n "$EFFECTIVE_LOGIN" ]] || EFFECTIVE_LOGIN=$(get_gitea_login_for_host "$host" 2>/dev/null || true)
|
||||||
# Bind the REST endpoint + token to the effective login, then derive
|
# Bind the REST endpoint + token to the effective login, then derive
|
||||||
# the acting identity from that SAME credential so the review submit
|
# the acting identity from that SAME credential so the review submit
|
||||||
# and its read-back verify against the identity that performed them.
|
# and its read-back verify against the identity that performed them.
|
||||||
@@ -741,7 +715,7 @@ elif [[ "$PLATFORM" == "gitea" ]]; then
|
|||||||
echo "Error: Comment required for request-changes"
|
echo "Error: Comment required for request-changes"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
# Best-effort host for credential resolution only (gitea_resolve_api_for_login
|
# Best-effort host for the tea-login GUESS only (gitea_resolve_api_for_login
|
||||||
# below re-derives the real host from HOST_OVERRIDE/remote independently and
|
# below re-derives the real host from HOST_OVERRIDE/remote independently and
|
||||||
# is authoritative). Prefer an explicit -H/--host; otherwise best-effort
|
# is authoritative). Prefer an explicit -H/--host; otherwise best-effort
|
||||||
# git-remote inference, tolerating its ABSENCE (a bare `get_remote_host` here
|
# git-remote inference, tolerating its ABSENCE (a bare `get_remote_host` here
|
||||||
@@ -749,13 +723,15 @@ elif [[ "$PLATFORM" == "gitea" ]]; then
|
|||||||
# SILENTLY — exit 1, zero output — even though -r/-H are exactly the flags
|
# SILENTLY — exit 1, zero output — even though -r/-H are exactly the flags
|
||||||
# that support running with no usable origin at all).
|
# that support running with no usable origin at all).
|
||||||
host="${HOST_OVERRIDE:-$(get_remote_host 2>/dev/null || true)}"
|
host="${HOST_OVERRIDE:-$(get_remote_host 2>/dev/null || true)}"
|
||||||
# A --login override selects a NAMED tea credential and is the only
|
# A --login override always wins. Otherwise name this host's login
|
||||||
# way to reach the tea store. With no --login there is deliberately no
|
# only as a best effort: the login name merely selects a per-login
|
||||||
# guess: gitea_resolve_api_for_login resolves this identity's own token.
|
# token, and gitea_resolve_api_for_login falls back to the host
|
||||||
# The guess this replaced named a SHARED account, selecting an identity
|
# credential (get_gitea_token) when no tea login is named — so a host
|
||||||
# the caller never asked to act as. The single resolved token is then
|
# tea's login list need not enumerate exotic (e.g. ported) hosts for
|
||||||
# used for the write, the /user identity, and the read-back.
|
# the default credential to resolve. The single resolved token is
|
||||||
|
# then used for the write, the /user identity, and the read-back.
|
||||||
EFFECTIVE_LOGIN="$LOGIN_OVERRIDE"
|
EFFECTIVE_LOGIN="$LOGIN_OVERRIDE"
|
||||||
|
[[ -n "$EFFECTIVE_LOGIN" ]] || EFFECTIVE_LOGIN=$(get_gitea_login_for_host "$host" 2>/dev/null || true)
|
||||||
gitea_resolve_api_for_login "$EFFECTIVE_LOGIN" "${LOGIN_OVERRIDE:+explicit}" || exit 1
|
gitea_resolve_api_for_login "$EFFECTIVE_LOGIN" "${LOGIN_OVERRIDE:+explicit}" || exit 1
|
||||||
ACTING_LOGIN=$(gitea_authenticated_login) || exit 1
|
ACTING_LOGIN=$(gitea_authenticated_login) || exit 1
|
||||||
head_sha=$(gitea_pr_head_sha "$PR_NUMBER") || exit 1
|
head_sha=$(gitea_pr_head_sha "$PR_NUMBER") || exit 1
|
||||||
@@ -770,7 +746,7 @@ elif [[ "$PLATFORM" == "gitea" ]]; then
|
|||||||
echo "Error: Comment required"
|
echo "Error: Comment required"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
# Best-effort host for credential resolution only (gitea_resolve_api_for_login
|
# Best-effort host for the tea-login GUESS only (gitea_resolve_api_for_login
|
||||||
# below re-derives the real host from HOST_OVERRIDE/remote independently and
|
# below re-derives the real host from HOST_OVERRIDE/remote independently and
|
||||||
# is authoritative). Prefer an explicit -H/--host; otherwise best-effort
|
# is authoritative). Prefer an explicit -H/--host; otherwise best-effort
|
||||||
# git-remote inference, tolerating its ABSENCE (a bare `get_remote_host` here
|
# git-remote inference, tolerating its ABSENCE (a bare `get_remote_host` here
|
||||||
@@ -778,13 +754,15 @@ elif [[ "$PLATFORM" == "gitea" ]]; then
|
|||||||
# SILENTLY — exit 1, zero output — even though -r/-H are exactly the flags
|
# SILENTLY — exit 1, zero output — even though -r/-H are exactly the flags
|
||||||
# that support running with no usable origin at all).
|
# that support running with no usable origin at all).
|
||||||
host="${HOST_OVERRIDE:-$(get_remote_host 2>/dev/null || true)}"
|
host="${HOST_OVERRIDE:-$(get_remote_host 2>/dev/null || true)}"
|
||||||
# A --login override selects a NAMED tea credential and is the only
|
# A --login override always wins. Otherwise name this host's login
|
||||||
# way to reach the tea store. With no --login there is deliberately no
|
# only as a best effort: the login name merely selects a per-login
|
||||||
# guess: gitea_resolve_api_for_login resolves this identity's own token.
|
# token, and gitea_resolve_api_for_login falls back to the host
|
||||||
# The guess this replaced named a SHARED account, selecting an identity
|
# credential (get_gitea_token) when no tea login is named — so a host
|
||||||
# the caller never asked to act as. The single resolved token is then
|
# tea's login list need not enumerate exotic (e.g. ported) hosts for
|
||||||
# used for the write, the /user identity, and the read-back.
|
# the default credential to resolve. The single resolved token is
|
||||||
|
# then used for the write, the /user identity, and the read-back.
|
||||||
EFFECTIVE_LOGIN="$LOGIN_OVERRIDE"
|
EFFECTIVE_LOGIN="$LOGIN_OVERRIDE"
|
||||||
|
[[ -n "$EFFECTIVE_LOGIN" ]] || EFFECTIVE_LOGIN=$(get_gitea_login_for_host "$host" 2>/dev/null || true)
|
||||||
gitea_resolve_api_for_login "$EFFECTIVE_LOGIN" "${LOGIN_OVERRIDE:+explicit}" || exit 1
|
gitea_resolve_api_for_login "$EFFECTIVE_LOGIN" "${LOGIN_OVERRIDE:+explicit}" || exit 1
|
||||||
ACTING_LOGIN=$(gitea_authenticated_login) || exit 1
|
ACTING_LOGIN=$(gitea_authenticated_login) || exit 1
|
||||||
comment_id=$(gitea_create_comment_verified "$PR_NUMBER" "$COMMENT" "$ACTING_LOGIN") || {
|
comment_id=$(gitea_create_comment_verified "$PR_NUMBER" "$COMMENT" "$ACTING_LOGIN") || {
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# Regression: detect_platform / get_repo_info must FAIL LOUDLY outside a git repo,
|
|
||||||
# not kill the caller silently.
|
|
||||||
#
|
|
||||||
# Both functions already contained the right error path:
|
|
||||||
# if [[ -z "$remote_url" ]]; then echo "error: not a git repository..." >&2; return 1; fi
|
|
||||||
# but under `set -e` -- which every wrapper in this directory uses -- the preceding
|
|
||||||
# assignment `remote_url=$(git remote get-url origin 2>/dev/null)` returns git's 128
|
|
||||||
# outside a repo and terminates the CALLER first. The message was unreachable.
|
|
||||||
#
|
|
||||||
# Observed cost: pr-review.sh invoked from a non-repo cwd exits 128 with NO stdout and
|
|
||||||
# NO stderr, even when -r/--repo and -H/--host are supplied -- the flags documented as
|
|
||||||
# "skips git-remote inference". Two reviewer seats hit this and correctly reported
|
|
||||||
# `blocked` with no diagnostic to report.
|
|
||||||
#
|
|
||||||
# The control that matters is the LOUD one: asserting "rc != 0" passes on the broken
|
|
||||||
# build too, because 128 is also non-zero. The test must assert the MESSAGE.
|
|
||||||
set -uo pipefail
|
|
||||||
fail=0
|
|
||||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
|
|
||||||
|
|
||||||
run_outside() { # $1=function name -> "rc:sawmessage"
|
|
||||||
local fn="$1" out rc
|
|
||||||
out=$( cd "$TMP" && bash -c "set -e; source '$HERE/detect-platform.sh'; $fn" 2>&1 ); rc=$?
|
|
||||||
printf '%s:%s' "$rc" "$(grep -qi 'not a git repository' <<<"$out" && echo yes || echo no)"
|
|
||||||
}
|
|
||||||
check() { if [ "$2" = "$3" ]; then echo " PASS $1 ($2)"; else echo " FAIL $1: got $2, want $3"; fail=1; fi; }
|
|
||||||
|
|
||||||
# $TMP must not be inside a git repo. Do not SKIP on failure: be-coder-07 showed the
|
|
||||||
# original SKIP exited 0, so pointing TMPDIR beneath a git worktree made this test PASS
|
|
||||||
# against unchanged main. A skip that exits 0 is indistinguishable from a pass.
|
|
||||||
# GIT_CEILING_DIRECTORIES stops git walking above $TMP, making the condition hold
|
|
||||||
# regardless of where TMPDIR lives, rather than merely detecting when it does not.
|
|
||||||
# GIT_CEILING_DIRECTORIES is matched against the PHYSICAL path -- a symlinked TMPDIR
|
|
||||||
# (/tmp is commonly one) makes the logical path never match, and the ceiling silently
|
|
||||||
# does nothing. Resolve it before exporting.
|
|
||||||
TMP="$(cd "$TMP" && pwd -P)"
|
|
||||||
export GIT_CEILING_DIRECTORIES="$TMP"
|
|
||||||
if ( cd "$TMP" && git rev-parse --git-dir >/dev/null 2>&1 ); then
|
|
||||||
echo " FAIL scratch dir is inside a git repo even with GIT_CEILING_DIRECTORIES set;"
|
|
||||||
echo " the outside-a-repo precondition cannot be established -- refusing to report a result"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "== outside a git repo: rc=1 AND the diagnostic is emitted =="
|
|
||||||
check "detect_platform" "$(run_outside detect_platform)" "1:yes"
|
|
||||||
check "get_repo_info" "$(run_outside get_repo_info)" "1:yes"
|
|
||||||
|
|
||||||
echo "== inside a git repo the functions still work =="
|
|
||||||
git init -q "$TMP/repo" 2>/dev/null
|
|
||||||
git -C "$TMP/repo" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git 2>/dev/null
|
|
||||||
out=$( cd "$TMP/repo" && bash -c "set -e; source '$HERE/detect-platform.sh'; detect_platform" 2>&1 ); rc=$?
|
|
||||||
if [ "$rc" -eq 0 ] && grep -qi 'gitea' <<<"$out"; then echo " PASS detect_platform in-repo (rc=0, $out)"
|
|
||||||
else echo " FAIL detect_platform in-repo: rc=$rc out=$out"; fail=1; fi
|
|
||||||
|
|
||||||
[ "$fail" -eq 0 ] && echo "OK detect-platform fails loudly outside a repo" || echo "FAILED"
|
|
||||||
exit "$fail"
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# Regression: the tea-failure diagnostic must be STATUS-NEUTRAL.
|
|
||||||
#
|
|
||||||
# Found by be-coder-08 reviewing PR #1086. At all three call sites the diagnostic is emitted
|
|
||||||
# immediately BEFORE the Gitea API fallback. Written as the last command of an && list:
|
|
||||||
# declare -F explain_... >/dev/null && explain_...
|
|
||||||
# under `set -e` a FAILING diagnostic exits and the fallback never runs -- a diagnostic that
|
|
||||||
# suppresses the recovery path it exists to explain. It misbehaves ONLY when the helper is
|
|
||||||
# PRESENT, so the helper-absent path (pre-#1086 behaviour) keeps working and reads as a
|
|
||||||
# passing control.
|
|
||||||
#
|
|
||||||
# TWO DEFECTS IN THE FIRST VERSION OF THIS TEST, both found by be-coder-08:
|
|
||||||
# 1. `out=$( ... ) 2>"$errto"` applies the redirection to the ASSIGNMENT, not to the
|
|
||||||
# command substitution, so the probe's stderr was never actually pointed at /dev/full
|
|
||||||
# and the /dev/full rows proved nothing. Verified: `out=$(echo x >&2) 2>/dev/full`
|
|
||||||
# leaks to the terminal and returns 0; the redirect must be INSIDE the substitution.
|
|
||||||
# 2. `eval "$CONSTRUCT"` changes `set -e` semantics for a bare && list, so the probe did
|
|
||||||
# not exercise the construct as the shipped file executes it. It now writes the line
|
|
||||||
# into a real script and runs it -- same parse, same set -e rules, no eval.
|
|
||||||
# The construct is still LIFTED FROM THE SHIPPED FILE: retyping the fixed form makes the
|
|
||||||
# probe pass on a build whose real call sites still carry the bare && form.
|
|
||||||
set -uo pipefail
|
|
||||||
fail=0
|
|
||||||
GIT_DIR_UNDER_TEST="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
|
|
||||||
|
|
||||||
probe() { # $1=present|absent $2=stderr target $3=source file -> "rc:fallback"
|
|
||||||
local helper="$1" errto="$2" src="$3" construct script out rc
|
|
||||||
construct=$(grep -m1 'explain_tea_user_does_not_exist' "$GIT_DIR_UNDER_TEST/$src" | sed 's/^[[:space:]]*//')
|
|
||||||
[ -n "$construct" ] || { printf 'no-construct:no'; return; }
|
|
||||||
script="$TMP/probe.sh"
|
|
||||||
{
|
|
||||||
echo '#!/bin/bash'
|
|
||||||
echo 'set -e'
|
|
||||||
echo 'explain_tea_user_does_not_exist() { echo "diagnostic" >&2; }'
|
|
||||||
[ "$helper" = absent ] && echo 'unset -f explain_tea_user_does_not_exist'
|
|
||||||
echo "$construct" # the shipped line, parsed by a real shell
|
|
||||||
echo 'echo FALLBACK_REACHED'
|
|
||||||
} > "$script"
|
|
||||||
# redirect INSIDE the substitution so the subshell's stderr really is $errto
|
|
||||||
out=$( bash "$script" 2>"$errto" ); rc=$?
|
|
||||||
printf '%s:%s' "$rc" "$(grep -q FALLBACK_REACHED <<<"$out" && echo yes || echo no)"
|
|
||||||
}
|
|
||||||
|
|
||||||
check() { if [ "$2" = "$3" ]; then echo " PASS $1 ($2)"; else echo " FAIL $1: got $2, want $3"; fail=1; fi; }
|
|
||||||
|
|
||||||
echo "== diagnostic must not alter exit status or skip the fallback =="
|
|
||||||
# /dev/full makes every stderr write fail -- the real-world shape is a closed or full fd.
|
|
||||||
for src in pr-create.sh issue-view.sh issue-create.sh; do
|
|
||||||
check "$src stderr OK / helper present" "$(probe present /dev/null "$src")" "0:yes"
|
|
||||||
check "$src stderr OK / helper absent " "$(probe absent /dev/null "$src")" "0:yes"
|
|
||||||
check "$src stderr FAILING / helper present" "$(probe present /dev/full "$src")" "0:yes"
|
|
||||||
check "$src stderr FAILING / helper absent " "$(probe absent /dev/full "$src")" "0:yes"
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "== all three call sites use the status-neutral form =="
|
|
||||||
for f in pr-create.sh issue-view.sh issue-create.sh; do
|
|
||||||
p="$GIT_DIR_UNDER_TEST/$f"
|
|
||||||
grep -q '{ declare -F explain_tea_user_does_not_exist >/dev/null && explain_tea_user_does_not_exist; } || true' "$p" \
|
|
||||||
&& echo " PASS $f guarded" || { echo " FAIL $f: diagnostic is not status-neutral"; fail=1; }
|
|
||||||
done
|
|
||||||
|
|
||||||
[ "$fail" -eq 0 ] && echo "OK diagnostic is status-neutral" || echo "FAILED"
|
|
||||||
exit "$fail"
|
|
||||||
@@ -7,40 +7,14 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|||||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/gitea-login-resolution}"
|
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/gitea-login-resolution}"
|
||||||
REPO_DIR="$WORK_DIR/repo"
|
REPO_DIR="$WORK_DIR/repo"
|
||||||
BIN_DIR="$WORK_DIR/bin"
|
BIN_DIR="$WORK_DIR/bin"
|
||||||
HOME_DIR="$WORK_DIR/home"
|
|
||||||
LOG_FILE="$WORK_DIR/calls.log"
|
LOG_FILE="$WORK_DIR/calls.log"
|
||||||
CREDENTIALS_FILE="$WORK_DIR/credentials.json"
|
CREDENTIALS_FILE="$WORK_DIR/credentials.json"
|
||||||
|
|
||||||
rm -rf "$WORK_DIR"
|
rm -rf "$WORK_DIR"
|
||||||
mkdir -p "$REPO_DIR" "$BIN_DIR" "$HOME_DIR"
|
mkdir -p "$REPO_DIR" "$BIN_DIR"
|
||||||
|
|
||||||
git -C "$REPO_DIR" init -q
|
git -C "$REPO_DIR" init -q
|
||||||
git -C "$REPO_DIR" remote add origin https://git.uscllc.com/USC/uconnect.git
|
git -C "$REPO_DIR" remote add origin https://git.uscllc.com/USC/uconnect.git
|
||||||
# HERMETICITY (#1007) — TWO mechanisms with DIFFERENT jobs; do not conflate them.
|
|
||||||
#
|
|
||||||
# OPERATIVE: the empty repo-local `mosaic.gitIdentity` below. get_gitea_token()
|
|
||||||
# step 0 resolves a per-agent identity from `git config --get mosaic.gitIdentity`,
|
|
||||||
# which on a provisioned agent seat is set GLOBALLY and so leaks into this fresh
|
|
||||||
# repo. It then reads a REAL per-slot token from $HOME and returns it WITHOUT ever
|
|
||||||
# consulting MOSAIC_CREDENTIALS_FILE, so the fixture credentials below are silently
|
|
||||||
# ignored. This suite is the one where the consequence is not subtle: it FAILS
|
|
||||||
# outright on a provisioned seat (rc=1 bare, rc=0 with $HOME sandboxed, one
|
|
||||||
# variable changed) and passes everywhere else, including CI, which has no
|
|
||||||
# per-agent token to leak.
|
|
||||||
#
|
|
||||||
# CONTAINMENT: the sandboxed HOME in the four run helpers below. It only has to
|
|
||||||
# bound a failure that the pin should already have prevented.
|
|
||||||
#
|
|
||||||
# NOTE FOR ANYONE AUDITING THIS SUITE: the sandboxed HOME is containment, NOT an
|
|
||||||
# assay. Running a suite under a decoy HOME to test for this defect REMOVES the
|
|
||||||
# trigger — ~/.gitconfig is where the global identity lives, so step 0 is skipped
|
|
||||||
# by construction and every suite reads clean however vulnerable it is. To measure,
|
|
||||||
# REPLICATE a seat (a decoy HOME whose .gitconfig sets mosaic.gitIdentity, with no
|
|
||||||
# per-slot token) so step 0 reaches its fail-loud branch.
|
|
||||||
#
|
|
||||||
# Note the env-var route does NOT work: detect-platform.sh reads
|
|
||||||
# "${MOSAIC_GIT_IDENTITY:-}", and `:-` treats set-but-empty identically to unset.
|
|
||||||
git -C "$REPO_DIR" config mosaic.gitIdentity ""
|
|
||||||
|
|
||||||
cat > "$CREDENTIALS_FILE" <<'JSON'
|
cat > "$CREDENTIALS_FILE" <<'JSON'
|
||||||
{
|
{
|
||||||
@@ -112,7 +86,6 @@ run_in_repo() {
|
|||||||
(
|
(
|
||||||
cd "$REPO_DIR"
|
cd "$REPO_DIR"
|
||||||
PATH="$BIN_DIR:$PATH" \
|
PATH="$BIN_DIR:$PATH" \
|
||||||
HOME="$HOME_DIR" \
|
|
||||||
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
||||||
MOSAIC_TEST_LOG="$LOG_FILE" \
|
MOSAIC_TEST_LOG="$LOG_FILE" \
|
||||||
"$@"
|
"$@"
|
||||||
@@ -310,7 +283,6 @@ run_in_repo2() {
|
|||||||
(
|
(
|
||||||
cd "$REPO_DIR"
|
cd "$REPO_DIR"
|
||||||
PATH="$BIN_DIR2:$PATH" \
|
PATH="$BIN_DIR2:$PATH" \
|
||||||
HOME="$HOME_DIR" \
|
|
||||||
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
||||||
MOSAIC_TEST_LOG="$LOG_FILE" \
|
MOSAIC_TEST_LOG="$LOG_FILE" \
|
||||||
"$@"
|
"$@"
|
||||||
@@ -371,7 +343,7 @@ write_fixture() { printf '%s' "$1" > "$FIXTURE_XDG/tea/config.yml"; }
|
|||||||
token_fallback() {
|
token_fallback() {
|
||||||
(
|
(
|
||||||
cd "$REPO_DIR"
|
cd "$REPO_DIR"
|
||||||
HOME="$HOME_DIR" XDG_CONFIG_HOME="$FIXTURE_XDG" PYTHONPATH="$NOYAML_DIR" bash -c '
|
XDG_CONFIG_HOME="$FIXTURE_XDG" PYTHONPATH="$NOYAML_DIR" bash -c '
|
||||||
source "'"$SCRIPT_DIR"'/detect-platform.sh"
|
source "'"$SCRIPT_DIR"'/detect-platform.sh"
|
||||||
get_gitea_token_for_login "$1" "$2"
|
get_gitea_token_for_login "$1" "$2"
|
||||||
' _ "$1" "$2"
|
' _ "$1" "$2"
|
||||||
@@ -382,7 +354,7 @@ token_fallback() {
|
|||||||
token_pyyaml() {
|
token_pyyaml() {
|
||||||
(
|
(
|
||||||
cd "$REPO_DIR"
|
cd "$REPO_DIR"
|
||||||
HOME="$HOME_DIR" XDG_CONFIG_HOME="$FIXTURE_XDG" bash -c '
|
XDG_CONFIG_HOME="$FIXTURE_XDG" bash -c '
|
||||||
source "'"$SCRIPT_DIR"'/detect-platform.sh"
|
source "'"$SCRIPT_DIR"'/detect-platform.sh"
|
||||||
get_gitea_token_for_login "$1" "$2"
|
get_gitea_token_for_login "$1" "$2"
|
||||||
' _ "$1" "$2"
|
' _ "$1" "$2"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Regression harness for #701: -h/--help must exit 0, bad args must still exit nonzero.
|
# Regression harness for #701: -h/--help must exit 0, bad args must still exit nonzero.
|
||||||
#
|
#
|
||||||
# Covers wrappers whose usage() previously hard-coded `exit 1`, so every
|
# Covers the 7 wrappers whose usage() previously hard-coded `exit 1`, so every
|
||||||
# --help invocation exited nonzero and logged a phantom isError across fleet lanes.
|
# --help invocation exited nonzero and logged a phantom isError across fleet lanes.
|
||||||
# Asserts, per wrapper:
|
# Asserts, per wrapper:
|
||||||
# 1. `--help` exits 0 and prints usage.
|
# 1. `--help` exits 0 and prints usage.
|
||||||
@@ -18,7 +18,6 @@ WRAPPERS=(
|
|||||||
issue-list.sh
|
issue-list.sh
|
||||||
milestone-create.sh
|
milestone-create.sh
|
||||||
pr-create.sh
|
pr-create.sh
|
||||||
pr-edit.sh
|
|
||||||
pr-list.sh
|
pr-list.sh
|
||||||
pr-merge.sh
|
pr-merge.sh
|
||||||
)
|
)
|
||||||
@@ -48,7 +47,7 @@ for wrapper in "${WRAPPERS[@]}"; do
|
|||||||
done
|
done
|
||||||
|
|
||||||
if [[ "$fail" -eq 0 ]]; then
|
if [[ "$fail" -eq 0 ]]; then
|
||||||
echo "help-exit-code regression passed (8/8 wrappers)"
|
echo "help-exit-code regression passed (7/7 wrappers)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
exit "$fail"
|
exit "$fail"
|
||||||
|
|||||||
@@ -1,150 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Regression: issue-close.sh must NOT close an issue when the closing comment could not
|
|
||||||
# be posted, and comment+close must be made by ONE principal.
|
|
||||||
#
|
|
||||||
# Guards two defects fixed together (see #1081):
|
|
||||||
# 1. `tea issue comment` is not a subcommand -- tea exposes comments as the TOP-LEVEL
|
|
||||||
# `tea comment`. The old call always failed, was unchecked, and the issue closed
|
|
||||||
# anyway, losing the record of WHY it was closed.
|
|
||||||
# 2. Routing the comment through the token-authenticated API helper while the close
|
|
||||||
# used --login would attribute one operation to two principals.
|
|
||||||
#
|
|
||||||
# SAFETY (rev-974, #1085 review 130): this test previously ran under `set -uo pipefail`
|
|
||||||
# with unchecked mkdir/redirect/cd, then prepended a possibly-nonexistent $MOCK_BIN to
|
|
||||||
# PATH -- while `git remote add origin` names the REAL repository. Forcing setup failure
|
|
||||||
# with an unwritable AGENT_WORK_ROOT made it `git init` in its CALLER's directory and
|
|
||||||
# invoke the real, provider-mutating issue-close.sh. Setup now fails closed, and both
|
|
||||||
# `tea` and `curl` are asserted to resolve INSIDE $MOCK_BIN before any target run.
|
|
||||||
set -euo pipefail
|
|
||||||
# NOTE: with `set -e`, `grep -q X && fail "..."` is a trap -- the ABSENT case (grep rc=1,
|
|
||||||
# which is the PASSING case for a must-not-appear assertion) is the last command of an &&
|
|
||||||
# list and silently terminates the script with no message. Every must-not-appear check
|
|
||||||
# below is therefore an if-block. This is the same set -e + &&-list defect be-coder-08
|
|
||||||
# found in #1086, reintroduced here by adding `set -e` for the sandbox-safety fix.
|
|
||||||
|
|
||||||
WORK_ROOT="${AGENT_WORK_ROOT:-${TMPDIR:-/tmp}}"
|
|
||||||
SANDBOX="$WORK_ROOT/issue-close-fail-closed-test-$$"
|
|
||||||
MOCK_BIN="$SANDBOX/bin"; REPO_DIR="$SANDBOX/repo"; CALLS="$SANDBOX/calls.log"
|
|
||||||
cleanup() { rm -rf "$SANDBOX"; }
|
|
||||||
trap cleanup EXIT
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
TARGET="$SCRIPT_DIR/issue-close.sh"
|
|
||||||
[ -f "$TARGET" ] || { echo "FAIL: issue-close.sh not found beside this test"; exit 1; }
|
|
||||||
fail() { echo "FAIL: $*"; exit 1; }
|
|
||||||
|
|
||||||
# Every setup step is checked. Under `set -e` these abort; the explicit || fail keeps the
|
|
||||||
# reason legible instead of a bare non-zero exit.
|
|
||||||
mkdir -p "$MOCK_BIN" "$REPO_DIR" || fail "setup: cannot create sandbox under $WORK_ROOT"
|
|
||||||
: > "$CALLS" || fail "setup: cannot write calls log at $CALLS"
|
|
||||||
cd "$REPO_DIR" || fail "setup: cannot cd into $REPO_DIR"
|
|
||||||
git init -q || fail "setup: git init failed"
|
|
||||||
git remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git || fail "setup: git remote add failed"
|
|
||||||
export PATH="$MOCK_BIN:$PATH" CALLS
|
|
||||||
export GITEA_URL="https://git.mosaicstack.dev"
|
|
||||||
export GITEA_TOKEN="redacted-test-token"
|
|
||||||
|
|
||||||
cat > "$MOCK_BIN/curl" <<'EOF'
|
|
||||||
#!/bin/bash
|
|
||||||
method=GET; url=""
|
|
||||||
while [ $# -gt 0 ]; do
|
|
||||||
case "$1" in
|
|
||||||
-X) method="$2"; shift 2 ;;
|
|
||||||
http*|https*) url="$1"; shift ;;
|
|
||||||
*) shift ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
printf 'curl %s %s\n' "$method" "$url" >> "$CALLS"
|
|
||||||
[ "${MOCK_CURL_FAIL:-}" = "1" ] && [ "$method" = "POST" ] && exit 22
|
|
||||||
exit 0
|
|
||||||
EOF
|
|
||||||
chmod +x "$MOCK_BIN/curl"
|
|
||||||
|
|
||||||
mk_tea() { # $1 = exit code for a comment attempt; $2 = login list (empty => no login)
|
|
||||||
local rc="$1" login="${2-}"
|
|
||||||
cat > "$MOCK_BIN/tea" <<EOF
|
|
||||||
#!/bin/bash
|
|
||||||
printf 'tea %s\n' "\$*" >> "$CALLS"
|
|
||||||
if [[ "\$*" == *"login list"* ]]; then
|
|
||||||
printf '%s\n' '${login}'; exit 0
|
|
||||||
fi
|
|
||||||
# Fail ANY comment attempt -- both the correct top-level \`tea comment\` and the broken
|
|
||||||
# \`tea issue comment\` -- so an unfixed script exercises the DEFECT rather than tripping
|
|
||||||
# a setup assertion.
|
|
||||||
if [[ "\$1" == "comment" || ( "\$1" == "issue" && "\$2" == "comment" ) ]]; then exit $rc; fi
|
|
||||||
exit 0
|
|
||||||
EOF
|
|
||||||
chmod +x "$MOCK_BIN/tea"
|
|
||||||
}
|
|
||||||
LOGIN_JSON='[{"name":"git.mosaicstack.dev","url":"https://git.mosaicstack.dev"}]'
|
|
||||||
|
|
||||||
# The mocks must be the ones that run. Without this, a failed setup silently falls through
|
|
||||||
# to the real tea/curl and the "test" mutates the real provider.
|
|
||||||
assert_mocked() {
|
|
||||||
local w
|
|
||||||
for w in tea curl; do
|
|
||||||
p=$(command -v "$w" || true)
|
|
||||||
[ -n "$p" ] || fail "SAFETY: $w does not resolve at all"
|
|
||||||
case "$p" in
|
|
||||||
"$MOCK_BIN"/*) : ;;
|
|
||||||
*) fail "SAFETY: $w resolves to $p, OUTSIDE the sandbox -- refusing to invoke the target" ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
}
|
|
||||||
|
|
||||||
run_target() { # never let a target failure abort the test; we assert on rc
|
|
||||||
# Call sites MUST use `rc=0; run_target ... || rc=$?` -- a bare `run_target ...; rc=$?`
|
|
||||||
# lets the non-zero RETURN trip set -e in the CALLER before rc is ever read.
|
|
||||||
set +e; bash "$TARGET" "$@" >/dev/null 2>&1; local rc=$?; set -e; return $rc
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── tea path ────────────────────────────────────────────────────────────────────────
|
|
||||||
# 1. NEGATIVE (the regression): comment fails => must NOT close, must exit non-zero
|
|
||||||
mk_tea 1 "$LOGIN_JSON"; : > "$CALLS"; assert_mocked
|
|
||||||
rc=0; run_target -i 42 -c "closing note" || rc=$?
|
|
||||||
grep -qE 'tea (issue )?comment' "$CALLS" || fail "no comment attempt -- setup did not reach the tea branch"
|
|
||||||
if grep -q 'tea issue close' "$CALLS"; then fail "ISSUE CLOSED AFTER THE COMMENT FAILED -- the regression"; fi
|
|
||||||
[ "$rc" -ne 0 ] || fail "comment failed but issue-close exited 0 -- FAIL-OPEN"
|
|
||||||
|
|
||||||
# 2. POSITIVE: comment succeeds => close proceeds, exit 0
|
|
||||||
mk_tea 0 "$LOGIN_JSON"; : > "$CALLS"; assert_mocked
|
|
||||||
rc=0; run_target -i 42 -c "closing note" || rc=$?
|
|
||||||
[ "$rc" -eq 0 ] || fail "comment succeeded but issue-close exited $rc"
|
|
||||||
grep -q 'tea issue close' "$CALLS" || fail "issue not closed even though the comment succeeded"
|
|
||||||
|
|
||||||
# 3. must use top-level `tea comment`, never `tea issue comment`
|
|
||||||
if grep -q 'tea issue comment' "$CALLS"; then fail "used 'tea issue comment' -- not a valid subcommand"; fi
|
|
||||||
|
|
||||||
# 4. ONE PRINCIPAL: comment and close must carry the SAME --login
|
|
||||||
c=$(grep -m1 '^tea comment' "$CALLS" | grep -o -- '--login [^ ]*' | awk '{print $2}')
|
|
||||||
k=$(grep -m1 '^tea issue close' "$CALLS" | grep -o -- '--login [^ ]*' | awk '{print $2}')
|
|
||||||
[ -n "$c" ] || fail "comment carried no --login"
|
|
||||||
[ "$c" = "$k" ] || fail "MIXED PRINCIPALS: comment=$c close=$k"
|
|
||||||
|
|
||||||
# ── no-login / API fallback path ────────────────────────────────────────────────────
|
|
||||||
# rev-974: the delta also adds fail-closed behaviour to this branch, and the suite never
|
|
||||||
# reached it -- replacing the whole fallback contract with an unconditional close still
|
|
||||||
# passed. These assert the POSTCONDITION (which HTTP calls happened, in what order),
|
|
||||||
# not merely that a command ran.
|
|
||||||
# 5. no login + comment FAILS => POST attempted, NO PATCH, non-zero
|
|
||||||
mk_tea 0 ""; : > "$CALLS"; assert_mocked
|
|
||||||
rc=0; MOCK_CURL_FAIL=1 run_target -i 42 -c "closing note" || rc=$?
|
|
||||||
grep -q 'curl POST' "$CALLS" || fail "API path: no comment POST attempted"
|
|
||||||
if grep -q 'curl PATCH' "$CALLS"; then fail "API path: ISSUE CLOSED (PATCH) AFTER THE COMMENT POST FAILED"; fi
|
|
||||||
[ "$rc" -ne 0 ] || fail "API path: comment failed but exited 0 -- FAIL-OPEN"
|
|
||||||
|
|
||||||
# 6. no login + comment SUCCEEDS => POST strictly BEFORE PATCH, exit 0
|
|
||||||
mk_tea 0 ""; : > "$CALLS"; assert_mocked
|
|
||||||
rc=0; run_target -i 42 -c "closing note" || rc=$?
|
|
||||||
[ "$rc" -eq 0 ] || fail "API path: comment succeeded but exited $rc"
|
|
||||||
order=$(grep -oE 'curl (POST|PATCH)' "$CALLS" | awk '{print $2}' | paste -sd, -)
|
|
||||||
[ "$order" = "POST,PATCH" ] || fail "API path: expected POST,PATCH -- got '${order:-<none>}'"
|
|
||||||
|
|
||||||
# 7. no login + NO comment => PATCH only, never a POST
|
|
||||||
mk_tea 0 ""; : > "$CALLS"; assert_mocked
|
|
||||||
rc=0; run_target -i 42 || rc=$?
|
|
||||||
[ "$rc" -eq 0 ] || fail "API path: no-comment close exited $rc"
|
|
||||||
if grep -q 'curl POST' "$CALLS"; then fail "API path: posted a comment when none was requested"; fi
|
|
||||||
grep -q 'curl PATCH' "$CALLS" || fail "API path: issue not closed when no comment was requested"
|
|
||||||
|
|
||||||
echo "issue-close.sh fail-closed + single-principal regression passed"
|
|
||||||
@@ -61,54 +61,15 @@ STATE_FILE="$WORK_DIR/comments.json"
|
|||||||
# A dedicated scratch dir the wrapper is pointed at via TMPDIR, so the leak
|
# A dedicated scratch dir the wrapper is pointed at via TMPDIR, so the leak
|
||||||
# check can assert every POST/GET body + metadata temp file is cleaned up.
|
# check can assert every POST/GET body + metadata temp file is cleaned up.
|
||||||
TMP_SCRATCH="$WORK_DIR/scratch"
|
TMP_SCRATCH="$WORK_DIR/scratch"
|
||||||
HOME_DIR="$WORK_DIR/home"
|
|
||||||
|
|
||||||
cleanup() {
|
cleanup() {
|
||||||
rm -rf "$WORK_DIR"
|
rm -rf "$WORK_DIR"
|
||||||
}
|
}
|
||||||
trap cleanup EXIT
|
trap cleanup EXIT
|
||||||
|
|
||||||
mkdir -p "$REPO_DIR" "$BIN_DIR" "$XDG_DIR" "$TMP_SCRATCH" "$HOME_DIR"
|
mkdir -p "$REPO_DIR" "$BIN_DIR" "$XDG_DIR" "$TMP_SCRATCH"
|
||||||
git -C "$REPO_DIR" init -q
|
git -C "$REPO_DIR" init -q
|
||||||
git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
|
git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
|
||||||
# HERMETICITY (#1007) — TWO mechanisms with DIFFERENT jobs; do not conflate them.
|
|
||||||
#
|
|
||||||
# OPERATIVE: the empty repo-local `mosaic.gitIdentity` below. get_gitea_token()
|
|
||||||
# step 0 resolves a per-agent identity from `git config --get mosaic.gitIdentity`,
|
|
||||||
# which on a provisioned agent seat is set GLOBALLY and so leaks into this fresh
|
|
||||||
# repo. It then reads a REAL per-slot token from $HOME and returns it WITHOUT ever
|
|
||||||
# consulting MOSAIC_CREDENTIALS_FILE, so the fixture credential below is silently
|
|
||||||
# ignored. The stub curl then rejects the unrecognised bearer, and this suite
|
|
||||||
# fails at its FIRST case with `Gitea authenticated-identity read failed with
|
|
||||||
# HTTP 401`. An empty repo-local value shadows the global one and reads back
|
|
||||||
# empty at rc=0. Measured: without this pin the suite is RED on every seat.
|
|
||||||
#
|
|
||||||
# CONTAINMENT: the sandboxed HOME in run_comment(). It only has to bound a
|
|
||||||
# failure that the pin should already have prevented.
|
|
||||||
#
|
|
||||||
# THIS SUITE WAS THE HARDEST OF THE FIVE TO SEE, and the reason is worth stating
|
|
||||||
# because it generalises: run_comment() sends the wrapper's stdout AND stderr to
|
|
||||||
# $OUTPUT_FILE, and the EXIT trap above deletes $WORK_DIR. So the 401 — the only
|
|
||||||
# thing that says what went wrong — exists only inside a directory that is gone
|
|
||||||
# by the time anyone looks. The suite exits 1 with ZERO bytes on stdout and
|
|
||||||
# stderr. A suite that discards or deletes its own evidence turns any post-hoc
|
|
||||||
# assay into a non-measurement: "nothing found" there means "no surviving
|
|
||||||
# trace", never "clean". It was found by intercepting the identity read at its
|
|
||||||
# SOURCE (a PATH shim over `git` logging every `mosaic.gitIdentity` read to a
|
|
||||||
# file outside $WORK_DIR), which is deletion-proof by construction, rather than
|
|
||||||
# by grepping for the symptom.
|
|
||||||
#
|
|
||||||
# NOTE FOR ANYONE AUDITING THIS SUITE: the sandboxed HOME is containment, NOT an
|
|
||||||
# assay. Running a suite under a decoy HOME to test for this defect REMOVES the
|
|
||||||
# trigger — ~/.gitconfig is where the global identity lives, so step 0 is skipped
|
|
||||||
# by construction and every suite reads clean however vulnerable it is. To
|
|
||||||
# measure, REPLICATE a seat (a decoy HOME whose .gitconfig sets
|
|
||||||
# mosaic.gitIdentity, with no per-slot token) so step 0 reaches its fail-loud
|
|
||||||
# branch — or intercept the read as described above.
|
|
||||||
#
|
|
||||||
# Note the env-var route does NOT work: detect-platform.sh reads
|
|
||||||
# "${MOSAIC_GIT_IDENTITY:-}", and `:-` treats set-but-empty identically to unset.
|
|
||||||
git -C "$REPO_DIR" config mosaic.gitIdentity ""
|
|
||||||
|
|
||||||
ISSUE_NUMBER=7
|
ISSUE_NUMBER=7
|
||||||
REPO_SLUG="mosaicstack/stack"
|
REPO_SLUG="mosaicstack/stack"
|
||||||
@@ -319,10 +280,7 @@ print("201")
|
|||||||
print(json.dumps(record))
|
print(json.dumps(record))
|
||||||
PY
|
PY
|
||||||
)
|
)
|
||||||
response_status="${result%%$'\n'*}"
|
write_response "$(printf '%s' "$result" | head -n1)" "$(printf '%s' "$result" | tail -n +2)"
|
||||||
response_body=""
|
|
||||||
[[ "$result" == *$'\n'* ]] && response_body="${result#*$'\n'}"
|
|
||||||
write_response "$response_status" "$response_body"
|
|
||||||
elif [[ "$method" == "GET" && "$path" == "$ISSUE_COMMENT_API_BASE"/issues/comments/* ]]; then
|
elif [[ "$method" == "GET" && "$path" == "$ISSUE_COMMENT_API_BASE"/issues/comments/* ]]; then
|
||||||
result=$(ISSUE_COMMENT_GET_ID="${path##*/}" python3 - <<'PY'
|
result=$(ISSUE_COMMENT_GET_ID="${path##*/}" python3 - <<'PY'
|
||||||
import json
|
import json
|
||||||
@@ -341,10 +299,7 @@ else:
|
|||||||
print(json.dumps(match))
|
print(json.dumps(match))
|
||||||
PY
|
PY
|
||||||
)
|
)
|
||||||
response_status="${result%%$'\n'*}"
|
write_response "$(printf '%s' "$result" | head -n1)" "$(printf '%s' "$result" | tail -n +2)"
|
||||||
response_body=""
|
|
||||||
[[ "$result" == *$'\n'* ]] && response_body="${result#*$'\n'}"
|
|
||||||
write_response "$response_status" "$response_body"
|
|
||||||
else
|
else
|
||||||
echo "Unexpected curl request: $method $url" >&2
|
echo "Unexpected curl request: $method $url" >&2
|
||||||
exit 97
|
exit 97
|
||||||
@@ -411,7 +366,6 @@ run_comment() {
|
|||||||
cd "$REPO_DIR"
|
cd "$REPO_DIR"
|
||||||
PATH="$BIN_DIR:$PATH" \
|
PATH="$BIN_DIR:$PATH" \
|
||||||
TMPDIR="$TMP_SCRATCH" \
|
TMPDIR="$TMP_SCRATCH" \
|
||||||
HOME="$HOME_DIR" \
|
|
||||||
XDG_CONFIG_HOME="$XDG_DIR" \
|
XDG_CONFIG_HOME="$XDG_DIR" \
|
||||||
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
||||||
ISSUE_COMMENT_TEA_LOG="$TEA_LOG" \
|
ISSUE_COMMENT_TEA_LOG="$TEA_LOG" \
|
||||||
|
|||||||
@@ -7,38 +7,13 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|||||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/issue-create-interactive-auth}"
|
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/issue-create-interactive-auth}"
|
||||||
REPO_DIR="$WORK_DIR/repo"
|
REPO_DIR="$WORK_DIR/repo"
|
||||||
BIN_DIR="$WORK_DIR/bin"
|
BIN_DIR="$WORK_DIR/bin"
|
||||||
HOME_DIR="$WORK_DIR/home"
|
|
||||||
LOG_FILE="$WORK_DIR/calls.log"
|
LOG_FILE="$WORK_DIR/calls.log"
|
||||||
CREDENTIALS_FILE="$WORK_DIR/credentials.json"
|
CREDENTIALS_FILE="$WORK_DIR/credentials.json"
|
||||||
|
|
||||||
rm -rf "$WORK_DIR"
|
rm -rf "$WORK_DIR"
|
||||||
mkdir -p "$REPO_DIR" "$BIN_DIR" "$HOME_DIR"
|
mkdir -p "$REPO_DIR" "$BIN_DIR"
|
||||||
git -C "$REPO_DIR" init -q
|
git -C "$REPO_DIR" init -q
|
||||||
git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
|
git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
|
||||||
# HERMETICITY (#1007) — TWO mechanisms with DIFFERENT jobs; do not conflate them.
|
|
||||||
#
|
|
||||||
# OPERATIVE: the empty repo-local `mosaic.gitIdentity` below. get_gitea_token()
|
|
||||||
# step 0 resolves a per-agent identity from `git config --get mosaic.gitIdentity`,
|
|
||||||
# which on a provisioned agent seat is set GLOBALLY and so leaks into this fresh
|
|
||||||
# repo. It then reads a REAL per-slot token from $HOME and returns it WITHOUT ever
|
|
||||||
# consulting MOSAIC_CREDENTIALS_FILE, so the fixture credential below is silently
|
|
||||||
# ignored and the suite runs against a production credential. An empty repo-local
|
|
||||||
# value shadows the global one and reads back empty at rc=0. Measured: this suite
|
|
||||||
# resolves a per-slot token without it.
|
|
||||||
#
|
|
||||||
# CONTAINMENT: the sandboxed HOME in run_wrapper(). It only has to bound a failure
|
|
||||||
# that the pin should already have prevented.
|
|
||||||
#
|
|
||||||
# NOTE FOR ANYONE AUDITING THIS SUITE: the sandboxed HOME is containment, NOT an
|
|
||||||
# assay. Running a suite under a decoy HOME to test for this defect REMOVES the
|
|
||||||
# trigger — ~/.gitconfig is where the global identity lives, so step 0 is skipped
|
|
||||||
# by construction and every suite reads clean however vulnerable it is. To measure,
|
|
||||||
# REPLICATE a seat (a decoy HOME whose .gitconfig sets mosaic.gitIdentity, with no
|
|
||||||
# per-slot token) so step 0 reaches its fail-loud branch.
|
|
||||||
#
|
|
||||||
# Note the env-var route does NOT work: detect-platform.sh reads
|
|
||||||
# "${MOSAIC_GIT_IDENTITY:-}", and `:-` treats set-but-empty identically to unset.
|
|
||||||
git -C "$REPO_DIR" config mosaic.gitIdentity ""
|
|
||||||
|
|
||||||
cat > "$CREDENTIALS_FILE" <<'JSON'
|
cat > "$CREDENTIALS_FILE" <<'JSON'
|
||||||
{"gitea":{"mosaicstack":{"url":"https://git.mosaicstack.dev","token":"test-token"}}}
|
{"gitea":{"mosaicstack":{"url":"https://git.mosaicstack.dev","token":"test-token"}}}
|
||||||
@@ -75,7 +50,6 @@ run_wrapper() {
|
|||||||
(
|
(
|
||||||
cd "$REPO_DIR"
|
cd "$REPO_DIR"
|
||||||
PATH="$BIN_DIR:$PATH" \
|
PATH="$BIN_DIR:$PATH" \
|
||||||
HOME="$HOME_DIR" \
|
|
||||||
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
||||||
MOSAIC_TEST_LOG="$LOG_FILE" \
|
MOSAIC_TEST_LOG="$LOG_FILE" \
|
||||||
"$@"
|
"$@"
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ section_nums() { # $1 = output $2 = header-prefix
|
|||||||
}
|
}
|
||||||
|
|
||||||
fail() { echo "FAIL: $1" >&2; exit 1; }
|
fail() { echo "FAIL: $1" >&2; exit 1; }
|
||||||
contains() { grep -qx "$2" <<<"$1"; }
|
contains() { printf '%s\n' "$1" | grep -qx "$2"; }
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Fixed (current) script behavior
|
# Fixed (current) script behavior
|
||||||
|
|||||||
@@ -1,95 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# test-mosaic-worktree-large-repo.sh — the helper must work on the repos it exists for.
|
|
||||||
#
|
|
||||||
# resolve_repo() took the first line of `git worktree list --porcelain` with
|
|
||||||
# `awk '/^worktree /{print substr($0,10); exit}'`. The `exit` closes the read end
|
|
||||||
# of the pipe while git is still writing, git takes SIGPIPE, and under
|
|
||||||
# `set -euo pipefail` the command substitution returns 141 — so the assignment
|
|
||||||
# fails, `set -e` aborts the function, and the script dies printing NOTHING. No
|
|
||||||
# message, no path, no worktree, exit 141.
|
|
||||||
#
|
|
||||||
# What makes it worth a dedicated test rather than a fixture line is WHEN it
|
|
||||||
# fires. If git finishes writing before awk leaves, there is no SIGPIPE and
|
|
||||||
# everything works. So the failure is a function of how much porcelain the repo
|
|
||||||
# produces: invisible on a three-worktree repo, reliable on a seventy-worktree
|
|
||||||
# one. It was measured on a repo with 73 worktrees (10 KB of porcelain) — rc=141,
|
|
||||||
# no output — and it had passed every hand-check before that, on small repos.
|
|
||||||
#
|
|
||||||
# A test that ran `git worktree list` against whatever repo it happens to sit in
|
|
||||||
# would inherit that same size dependence and would have PASSED on the tree that
|
|
||||||
# was broken. So git is stubbed on PATH and made to emit a large porcelain
|
|
||||||
# stream, which turns "depends on the repo you are standing in" into "always".
|
|
||||||
#
|
|
||||||
# Exit: 0 = the helper resolved the repo · 1 = it did not
|
|
||||||
|
|
||||||
set -uo pipefail
|
|
||||||
|
|
||||||
HERE="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
TOOL="${1:-$HERE/mosaic-worktree.sh}"
|
|
||||||
[ -x "$TOOL" ] || { printf 'test-mosaic-worktree-large-repo: not executable: %s\n' "$TOOL" >&2; exit 2; }
|
|
||||||
|
|
||||||
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
|
|
||||||
mkdir -p "$TMP/bin"
|
|
||||||
|
|
||||||
# The stub answers exactly the two calls resolve_repo makes, and answers the
|
|
||||||
# porcelain one with ~450 KB — comfortably past a 64 KB pipe buffer, so the
|
|
||||||
# writer is still writing when a reader that quits early goes away. Anything
|
|
||||||
# else exits non-zero rather than pretending to be git.
|
|
||||||
cat > "$TMP/bin/git" <<'STUB'
|
|
||||||
#!/bin/sh
|
|
||||||
while [ $# -gt 0 ]; do
|
|
||||||
case "$1" in -C) shift 2 ;; *) break ;; esac
|
|
||||||
done
|
|
||||||
case "$*" in
|
|
||||||
"rev-parse --git-dir")
|
|
||||||
echo .git; exit 0 ;;
|
|
||||||
"worktree list --porcelain")
|
|
||||||
# The first entry is the main worktree. That single line is all the helper
|
|
||||||
# needs, and it is exactly what it stopped receiving.
|
|
||||||
printf 'worktree /src/fakerepo\nHEAD %040d\nbranch refs/heads/main\n\n' 0
|
|
||||||
awk 'BEGIN{ for (i = 0; i < 4000; i++)
|
|
||||||
printf "worktree /src/fakerepo-worktrees/w%d\nHEAD %040d\nbranch refs/heads/topic-%d\n\n", i, 0, i }'
|
|
||||||
# NOT `exit 0`. Real git dies of SIGPIPE here and reports 141, and pipefail
|
|
||||||
# in the caller is what turns that into the silent abort. A stub that exits 0
|
|
||||||
# regardless hands the caller a clean status and the probe passes on the
|
|
||||||
# broken tree — which is how this test failed to be a test on its first run.
|
|
||||||
exit $? ;;
|
|
||||||
esac
|
|
||||||
exit 1
|
|
||||||
STUB
|
|
||||||
chmod +x "$TMP/bin/git"
|
|
||||||
|
|
||||||
fail=0
|
|
||||||
check() {
|
|
||||||
local why="$1" want="$2" got="$3"
|
|
||||||
if [ "$want" = "$got" ]; then
|
|
||||||
printf 'ok %s\n' "$why"
|
|
||||||
else
|
|
||||||
printf 'FAIL %s\n want: %s\n got: %s\n' "$why" "$want" "$got"
|
|
||||||
fail=1
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
out="$(PATH="$TMP/bin:$PATH" "$TOOL" path feat/workspace-hygiene 2>&1)"
|
|
||||||
rc=$?
|
|
||||||
|
|
||||||
# Both halves are asserted. rc alone would pass if the helper started printing a
|
|
||||||
# usage error, and output alone would miss a non-zero exit — and the defect's
|
|
||||||
# signature is precisely a non-zero exit with no output, which only the pair
|
|
||||||
# distinguishes from every other way this could go wrong.
|
|
||||||
check 'resolving a repo with a large worktree list exits 0' 0 "$rc"
|
|
||||||
check 'and derives the path from the main worktree' /src/fakerepo-worktrees/feat-workspace-hygiene "$out"
|
|
||||||
|
|
||||||
printf '\n'
|
|
||||||
if [ "$fail" -eq 0 ]; then
|
|
||||||
printf 'mosaic-worktree: resolves against a large porcelain stream.\n'
|
|
||||||
else
|
|
||||||
cat <<'EOF'
|
|
||||||
mosaic-worktree could not resolve the repository.
|
|
||||||
|
|
||||||
An empty output with a non-zero exit is the SIGPIPE signature: a reader that
|
|
||||||
quits early (`awk ... exit`, `head -n`) kills the producer, and pipefail turns
|
|
||||||
that into a silent abort. Nothing in this script may close a git pipe early.
|
|
||||||
EOF
|
|
||||||
fi
|
|
||||||
exit "$fail"
|
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Regression harness for secret-safe, identity-bound PR editing and explicit targets.
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-edit}"
|
|
||||||
REPO_DIR="$WORK_DIR/repo"; BIN_DIR="$WORK_DIR/bin"; HOME_DIR="$WORK_DIR/home"
|
|
||||||
XDG_DIR="$WORK_DIR/xdg"; LOG_FILE="$WORK_DIR/calls.log"
|
|
||||||
rm -rf "$WORK_DIR"; mkdir -p "$REPO_DIR" "$BIN_DIR" "$HOME_DIR" "$XDG_DIR/tea"
|
|
||||||
git -C "$REPO_DIR" init -q
|
|
||||||
git -C "$REPO_DIR" remote add origin https://git.uscllc.com/other/wrong-checkout.git
|
|
||||||
git -C "$REPO_DIR" config mosaic.gitIdentity ""
|
|
||||||
|
|
||||||
cat > "$XDG_DIR/tea/config.yml" <<'YAML'
|
|
||||||
logins:
|
|
||||||
- name: usc-coder3
|
|
||||||
url: https://git.uscllc.com
|
|
||||||
token: fixture-usc-token
|
|
||||||
- name: same-host-other
|
|
||||||
url: https://git.uscllc.com
|
|
||||||
token: fixture-other-token
|
|
||||||
- name: mosaic-coder3
|
|
||||||
url: https://git.mosaicstack.dev
|
|
||||||
token: fixture-mosaic-token
|
|
||||||
YAML
|
|
||||||
|
|
||||||
cat > "$BIN_DIR/tea" <<'SH'
|
|
||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
# Deliberately misleading duplicate-name response: the wrapper must never use
|
|
||||||
# tea for identity validation because its name lookup is not host-bound.
|
|
||||||
[[ "$*" == "api --login duplicate /user" ]] && { printf '{"login":"coder3"}\n'; exit 0; }
|
|
||||||
exit 1
|
|
||||||
SH
|
|
||||||
cat > "$BIN_DIR/curl" <<'SH'
|
|
||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
printf 'curl' >> "$MOSAIC_TEST_LOG"; printf ' <%s>' "$@" >> "$MOSAIC_TEST_LOG"; printf '\n' >> "$MOSAIC_TEST_LOG"
|
|
||||||
if [[ "${*: -1}" == */user ]]; then
|
|
||||||
printf '{"login":"%s"}\n' "${MOSAIC_STUB_AUTH_USER:-coder3}"
|
|
||||||
elif [[ "${*: -1}" == */repos/* && " $* " != *" -X PATCH "* ]]; then
|
|
||||||
[[ "${MOSAIC_STUB_SIGNAL:-}" == "TERM" ]] && { kill -TERM "$PPID"; sleep 1; }
|
|
||||||
[[ "${MOSAIC_STUB_SIGNAL:-}" == "INT" ]] && { kill -INT "$PPID"; sleep 1; }
|
|
||||||
printf '{"name":"repo"}\n'
|
|
||||||
else
|
|
||||||
printf '{"number":42,"draft":false}\n'
|
|
||||||
fi
|
|
||||||
SH
|
|
||||||
cat > "$BIN_DIR/gh" <<'SH'
|
|
||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
printf 'gh' >> "$MOSAIC_TEST_LOG"; printf ' <%s>' "$@" >> "$MOSAIC_TEST_LOG"; printf '\n' >> "$MOSAIC_TEST_LOG"
|
|
||||||
SH
|
|
||||||
chmod +x "$BIN_DIR/tea" "$BIN_DIR/curl" "$BIN_DIR/gh" "$SCRIPT_DIR/pr-edit.sh"
|
|
||||||
|
|
||||||
run_wrapper() {
|
|
||||||
(cd "$REPO_DIR"; PATH="$BIN_DIR:$PATH" HOME="$HOME_DIR" XDG_CONFIG_HOME="$XDG_DIR" \
|
|
||||||
MOSAIC_TEST_LOG="$LOG_FILE" "$SCRIPT_DIR/pr-edit.sh" "$@")
|
|
||||||
}
|
|
||||||
assert_no_secret() {
|
|
||||||
! grep -q 'fixture-.*-token' "$LOG_FILE" || { echo "Credential leaked into curl argv/log" >&2; exit 1; }
|
|
||||||
}
|
|
||||||
|
|
||||||
# The explicit target differs from CWD origin and must govern BOTH host and slug.
|
|
||||||
: > "$LOG_FILE"
|
|
||||||
# shellcheck disable=SC2016 # literal backticks prove argument-array body safety.
|
|
||||||
MOSAIC_GIT_IDENTITY=coder3 run_wrapper -n 42 --login mosaic-coder3 -r mosaicstack/stack \
|
|
||||||
-H git.mosaicstack.dev --title 'New title' --body 'Body with `literal` bytes' --base develop --draft >/dev/null
|
|
||||||
python3 - "$LOG_FILE" <<'PY'
|
|
||||||
import json, pathlib, sys
|
|
||||||
lines = pathlib.Path(sys.argv[1]).read_text().splitlines()
|
|
||||||
assert len(lines) == 3, lines
|
|
||||||
assert "https://git.mosaicstack.dev/api/v1/user" in lines[0], lines
|
|
||||||
assert "https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack" in lines[1], lines
|
|
||||||
assert "https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/pulls/42" in lines[2], lines
|
|
||||||
assert all("--config" in line for line in lines), lines
|
|
||||||
assert "Authorization:" not in "\n".join(lines), lines
|
|
||||||
payload = lines[2].split(" <-d> <", 1)[1].split("> <https://", 1)[0]
|
|
||||||
assert json.loads(payload) == {"title":"New title","body":"Body with `literal` bytes","base":"develop","draft":True}
|
|
||||||
PY
|
|
||||||
assert_no_secret
|
|
||||||
|
|
||||||
# Ready maps to false and still preflights before the write.
|
|
||||||
: > "$LOG_FILE"
|
|
||||||
MOSAIC_GIT_IDENTITY=coder3 run_wrapper -n 42 --login usc-coder3 -r USC/uconnect -H git.uscllc.com --ready >/dev/null
|
|
||||||
grep -q '"draft": false' "$LOG_FILE"; assert_no_secret
|
|
||||||
|
|
||||||
# Identity is mandatory; no ambient/first-host login can write.
|
|
||||||
: > "$LOG_FILE"
|
|
||||||
if run_wrapper -n 42 --login usc-coder3 --draft >/dev/null 2>&1; then echo "Unset identity wrote" >&2; exit 1; fi
|
|
||||||
[[ ! -s "$LOG_FILE" ]] || { echo "Unset identity reached curl" >&2; exit 1; }
|
|
||||||
|
|
||||||
# Explicit and ambient same-host wrong principals both refuse after identity
|
|
||||||
# lookup but before repo preflight/PATCH. The /user read is expected curl #1.
|
|
||||||
for mode in explicit ambient; do
|
|
||||||
: > "$LOG_FILE"
|
|
||||||
if [[ "$mode" == explicit ]]; then
|
|
||||||
cmd=(--login same-host-other)
|
|
||||||
else
|
|
||||||
cmd=(); export GITEA_LOGIN=same-host-other
|
|
||||||
fi
|
|
||||||
if MOSAIC_STUB_AUTH_USER=other MOSAIC_GIT_IDENTITY=coder3 run_wrapper -n 42 "${cmd[@]}" -r USC/uconnect -H git.uscllc.com --draft >/dev/null 2>&1; then
|
|
||||||
echo "$mode wrong identity wrote" >&2; exit 1
|
|
||||||
fi
|
|
||||||
unset GITEA_LOGIN
|
|
||||||
[[ "$(wc -l < "$LOG_FILE")" -eq 1 ]] || { echo "$mode wrong identity passed identity lookup" >&2; exit 1; }
|
|
||||||
! grep -q '/repos/' "$LOG_FILE" || { echo "$mode wrong identity reached repo preflight/PATCH" >&2; exit 1; }
|
|
||||||
done
|
|
||||||
|
|
||||||
# Set identity with no explicit/ambient login refuses rather than selecting first host login.
|
|
||||||
: > "$LOG_FILE"
|
|
||||||
if MOSAIC_GIT_IDENTITY=coder3 run_wrapper -n 42 -r USC/uconnect -H git.uscllc.com --draft >/dev/null 2>&1; then
|
|
||||||
echo "Missing login selected a principal" >&2; exit 1
|
|
||||||
fi
|
|
||||||
[[ ! -s "$LOG_FILE" ]] || { echo "Missing login reached curl" >&2; exit 1; }
|
|
||||||
|
|
||||||
# Split-credential probe for the duplicate-name cross-host seam: tea's
|
|
||||||
# name-only /user would report coder3, while the selected host-bound curl token
|
|
||||||
# reports other. The wrapper must trust only the latter handle used by PATCH.
|
|
||||||
: > "$LOG_FILE"
|
|
||||||
if MOSAIC_STUB_AUTH_USER=other MOSAIC_GIT_IDENTITY=coder3 run_wrapper -n 42 --login mosaic-coder3 \
|
|
||||||
-r mosaicstack/stack -H git.mosaicstack.dev --draft >/dev/null 2>&1; then
|
|
||||||
echo "Duplicate-name split credential reached PATCH" >&2; exit 1
|
|
||||||
fi
|
|
||||||
[[ "$(wc -l < "$LOG_FILE")" -eq 1 ]] || { echo "Duplicate-name identity mismatch passed /user" >&2; cat "$LOG_FILE" >&2; exit 1; }
|
|
||||||
! grep -q -- '-X> <PATCH' "$LOG_FILE" || { echo "Duplicate-name mismatch mutated" >&2; exit 1; }
|
|
||||||
|
|
||||||
# TERM and INT during repo preflight clean up, do not mutate, and return the
|
|
||||||
# signal status rather than swallowing termination into success.
|
|
||||||
for sig in TERM INT; do
|
|
||||||
: > "$LOG_FILE"
|
|
||||||
set +e
|
|
||||||
MOSAIC_STUB_SIGNAL="$sig" MOSAIC_GIT_IDENTITY=coder3 run_wrapper -n 42 --login usc-coder3 \
|
|
||||||
-r USC/uconnect -H git.uscllc.com --draft >/dev/null 2>&1
|
|
||||||
rc=$?
|
|
||||||
set -e
|
|
||||||
[[ "$rc" -ne 0 ]] || { echo "$sig was swallowed into success" >&2; exit 1; }
|
|
||||||
[[ "$rc" -eq 143 || "$rc" -eq 130 ]] || { echo "$sig returned unexpected status $rc" >&2; exit 1; }
|
|
||||||
! grep -q -- '-X> <PATCH' "$LOG_FILE" || { echo "$sig continued into PATCH" >&2; exit 1; }
|
|
||||||
assert_no_secret
|
|
||||||
done
|
|
||||||
|
|
||||||
# Cross-host credential fails before curl; explicit target preflight failure blocks PATCH.
|
|
||||||
: > "$LOG_FILE"
|
|
||||||
if MOSAIC_GIT_IDENTITY=coder3 run_wrapper -n 42 --login mosaic-coder3 -r USC/uconnect -H git.uscllc.com --draft >/dev/null 2>&1; then
|
|
||||||
echo "Cross-host login wrote" >&2; exit 1
|
|
||||||
fi
|
|
||||||
[[ ! -s "$LOG_FILE" ]] || { echo "Cross-host login reached curl" >&2; exit 1; }
|
|
||||||
|
|
||||||
if run_wrapper -n 42 --draft --ready >/dev/null 2>&1; then echo "Accepted conflicting modes" >&2; exit 1; fi
|
|
||||||
if run_wrapper -n 42 >/dev/null 2>&1; then echo "Accepted no-op edit" >&2; exit 1; fi
|
|
||||||
run_wrapper --help 2>&1 | grep -q '^Usage:'
|
|
||||||
|
|
||||||
# GitHub retains provider-native edit/readiness behavior.
|
|
||||||
git -C "$REPO_DIR" remote set-url origin https://github.com/acme/widgets.git
|
|
||||||
: > "$LOG_FILE"; run_wrapper -n 7 --title 'GitHub title' --draft >/dev/null
|
|
||||||
grep -q 'gh <pr> <edit> <7> <--title> <GitHub title>' "$LOG_FILE"
|
|
||||||
grep -q 'gh <pr> <ready> <7> <--undo>' "$LOG_FILE"
|
|
||||||
echo "PR edit regression harness passed"
|
|
||||||
@@ -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"
|
|
||||||
@@ -8,7 +8,6 @@ WORK_ROOT="${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}"
|
|||||||
SANDBOX="$WORK_ROOT/pr-merge-empty-uid-test-$$"
|
SANDBOX="$WORK_ROOT/pr-merge-empty-uid-test-$$"
|
||||||
MOCK_BIN="$SANDBOX/bin"
|
MOCK_BIN="$SANDBOX/bin"
|
||||||
REPO_DIR="$SANDBOX/repo"
|
REPO_DIR="$SANDBOX/repo"
|
||||||
HOME_DIR="$SANDBOX/home"
|
|
||||||
LOG_FILE="$SANDBOX/mock.log"
|
LOG_FILE="$SANDBOX/mock.log"
|
||||||
|
|
||||||
cleanup() {
|
cleanup() {
|
||||||
@@ -16,7 +15,7 @@ cleanup() {
|
|||||||
}
|
}
|
||||||
trap cleanup EXIT
|
trap cleanup EXIT
|
||||||
|
|
||||||
mkdir -p "$MOCK_BIN" "$REPO_DIR" "$HOME_DIR"
|
mkdir -p "$MOCK_BIN" "$REPO_DIR"
|
||||||
: > "$LOG_FILE"
|
: > "$LOG_FILE"
|
||||||
|
|
||||||
cat > "$MOCK_BIN/tea" <<'EOF'
|
cat > "$MOCK_BIN/tea" <<'EOF'
|
||||||
@@ -110,48 +109,7 @@ chmod +x "$MOCK_BIN/curl"
|
|||||||
cd "$REPO_DIR"
|
cd "$REPO_DIR"
|
||||||
git init -q
|
git init -q
|
||||||
git remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
|
git remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
|
||||||
# HERMETICITY (#1007) — TWO mechanisms with DIFFERENT jobs; do not conflate them.
|
|
||||||
#
|
|
||||||
# OPERATIVE: the empty repo-local `mosaic.gitIdentity` below. get_gitea_token()
|
|
||||||
# step 0 resolves a per-agent identity from `git config --get mosaic.gitIdentity`,
|
|
||||||
# which on a provisioned agent seat is set GLOBALLY and so leaks into this fresh
|
|
||||||
# repo. Step 0 runs BEFORE the credential loader AND before the GITEA_TOKEN env
|
|
||||||
# check, so the `GITEA_TOKEN=redacted-test-token` exported below is silently
|
|
||||||
# overridden and a REAL per-slot token from $HOME is what flows through the
|
|
||||||
# wrapper. Measured on a provisioned seat before this pin: all 5 mock-curl calls
|
|
||||||
# carried the real per-slot token in argv and the fixture token was never used at
|
|
||||||
# ALL. Three consequences specific to this suite:
|
|
||||||
# 1. pr-merge.sh passes the token as `-H "Authorization: token $token"` and the
|
|
||||||
# mock curl logs full argv, so the real credential is written to $LOG_FILE
|
|
||||||
# on disk — transiently: the suite truncates that file between phases and
|
|
||||||
# the EXIT trap removes $SANDBOX, so it leaves NO post-hoc trace. That is
|
|
||||||
# why this suite was the hardest of the three to detect; observing it needs
|
|
||||||
# an instrument that captures argv while the run is live.
|
|
||||||
# 2. Every failure path dumps $OUTPUT/$LOG_FILE to stderr through
|
|
||||||
# `sed 's/redacted-test-token/***REDACTED***/g'` — a redaction pattern that
|
|
||||||
# is the literal fixture string and therefore CANNOT match the token
|
|
||||||
# actually in use.
|
|
||||||
# 3. The leak assertion at "Token leaked to pr-merge.sh output" greps for that
|
|
||||||
# same fixture string, so on a provisioned seat it passes vacuously: it is
|
|
||||||
# searching for a value the run never used.
|
|
||||||
# An empty repo-local value shadows the global one and reads back empty at rc=0.
|
|
||||||
#
|
|
||||||
# CONTAINMENT: the sandboxed HOME exported below. It only has to bound a failure
|
|
||||||
# that the pin should already have prevented.
|
|
||||||
#
|
|
||||||
# NOTE FOR ANYONE AUDITING THIS SUITE: the sandboxed HOME is containment, NOT an
|
|
||||||
# assay. Running a suite under a decoy HOME to test for this defect REMOVES the
|
|
||||||
# trigger — ~/.gitconfig is where the global identity lives, so step 0 is skipped
|
|
||||||
# by construction and every suite reads clean however vulnerable it is. To measure,
|
|
||||||
# REPLICATE a seat (a decoy HOME whose .gitconfig sets mosaic.gitIdentity, with no
|
|
||||||
# per-slot token) so step 0 reaches its fail-loud branch.
|
|
||||||
#
|
|
||||||
# Note the env-var route does NOT work: detect-platform.sh reads
|
|
||||||
# "${MOSAIC_GIT_IDENTITY:-}", and `:-` treats set-but-empty identically to unset.
|
|
||||||
git -C "$REPO_DIR" config mosaic.gitIdentity ""
|
|
||||||
|
|
||||||
# $SANDBOX/$HOME_DIR were derived from the real $HOME above, before this export.
|
|
||||||
export HOME="$HOME_DIR"
|
|
||||||
export PATH="$MOCK_BIN:$PATH"
|
export PATH="$MOCK_BIN:$PATH"
|
||||||
export PR_MERGE_TEST_LOG="$LOG_FILE"
|
export PR_MERGE_TEST_LOG="$LOG_FILE"
|
||||||
export GITEA_LOGIN="git.mosaicstack.dev"
|
export GITEA_LOGIN="git.mosaicstack.dev"
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ cp "$SCRIPT_DIR/detect-platform.sh" "$FIXTURE_DIR/detect-platform.sh"
|
|||||||
|
|
||||||
cat > "$FIXTURE_DIR/pr-metadata.sh" <<'SH'
|
cat > "$FIXTURE_DIR/pr-metadata.sh" <<'SH'
|
||||||
#!/usr/bin/env bash
|
#!/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
|
SH
|
||||||
|
|
||||||
cat > "$FIXTURE_DIR/ci-queue-wait.sh" <<'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
|
cat "$CALL_LOG" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
if ! grep -q -- '-R mosaicstack/stack' "$CALL_LOG"; then
|
if ! grep -q -- '-R contributor/widgets-fork' "$CALL_LOG"; then
|
||||||
echo "FAIL: merge queue guard did not receive the BASE repository for CI status" >&2
|
echo "FAIL: merge queue guard did not receive the fork head repository" >&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
|
|
||||||
cat "$CALL_LOG" >&2
|
cat "$CALL_LOG" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -8,68 +8,12 @@ WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-metadata-gitea}"
|
|||||||
REPO_DIR="$WORK_DIR/repo"
|
REPO_DIR="$WORK_DIR/repo"
|
||||||
FIXTURE_DIR="$WORK_DIR/fixtures"
|
FIXTURE_DIR="$WORK_DIR/fixtures"
|
||||||
STUB_DIR="$WORK_DIR/stubs"
|
STUB_DIR="$WORK_DIR/stubs"
|
||||||
HOME_DIR="$WORK_DIR/home"
|
|
||||||
CREDENTIALS_FILE="$WORK_DIR/credentials.json"
|
|
||||||
|
|
||||||
rm -rf "$WORK_DIR"
|
rm -rf "$WORK_DIR"
|
||||||
mkdir -p "$REPO_DIR" "$FIXTURE_DIR" "$STUB_DIR" "$HOME_DIR"
|
mkdir -p "$REPO_DIR" "$FIXTURE_DIR" "$STUB_DIR"
|
||||||
|
|
||||||
git -C "$REPO_DIR" init -q
|
git -C "$REPO_DIR" init -q
|
||||||
git -C "$REPO_DIR" remote add origin https://git.uscllc.com/USC/uconnect.git
|
git -C "$REPO_DIR" remote add origin https://git.uscllc.com/USC/uconnect.git
|
||||||
# HERMETICITY (#1007) — TWO mechanisms with DIFFERENT jobs; do not conflate them.
|
|
||||||
#
|
|
||||||
# OPERATIVE: the empty repo-local `mosaic.gitIdentity` below. get_gitea_token()
|
|
||||||
# step 0 resolves a per-agent identity from `git config --get mosaic.gitIdentity`,
|
|
||||||
# which on a provisioned agent seat is set GLOBALLY and so leaks into this fresh
|
|
||||||
# repo. Step 0 runs BEFORE the credential loader AND before the GITEA_TOKEN env
|
|
||||||
# check, so the `GITEA_TOKEN="stub-token"` set in the run helpers below is
|
|
||||||
# silently overridden and a REAL per-slot token from $HOME is what reaches curl.
|
|
||||||
# Measured on a provisioned seat before this pin: both stub-curl calls carried
|
|
||||||
# the real token in argv. An empty repo-local value shadows the global one and
|
|
||||||
# reads back empty at rc=0.
|
|
||||||
#
|
|
||||||
# CONTAINMENT: the sandboxed HOME in the three run helpers below. It only has to
|
|
||||||
# bound a failure that the pin should already have prevented.
|
|
||||||
#
|
|
||||||
# NOTE FOR ANYONE AUDITING THIS SUITE: the sandboxed HOME is containment, NOT an
|
|
||||||
# assay. Running a suite under a decoy HOME to test for this defect REMOVES the
|
|
||||||
# trigger — ~/.gitconfig is where the global identity lives, so step 0 is skipped
|
|
||||||
# by construction and every suite reads clean however vulnerable it is. To measure,
|
|
||||||
# REPLICATE a seat (a decoy HOME whose .gitconfig sets mosaic.gitIdentity, with no
|
|
||||||
# per-slot token) so step 0 reaches its fail-loud branch. See
|
|
||||||
# test-gitea-token-identity.sh for the stronger `env -i HOME=…` form used where a
|
|
||||||
# suite's whole subject IS identity resolution.
|
|
||||||
#
|
|
||||||
# Note the env-var route does NOT work: detect-platform.sh reads
|
|
||||||
# "${MOSAIC_GIT_IDENTITY:-}", and `:-` treats set-but-empty identically to unset.
|
|
||||||
git -C "$REPO_DIR" config mosaic.gitIdentity ""
|
|
||||||
|
|
||||||
# The pin above removes step 0, but this suite has a SECOND, independent
|
|
||||||
# dependency on operator state, and closing only the first would leave the suite
|
|
||||||
# red on any hermetic environment. The `GITEA_TOKEN="stub-token"` /
|
|
||||||
# `GITEA_URL="https://git.example.test"` pair the run helpers set is INERT: step 2
|
|
||||||
# of get_gitea_token accepts GITEA_TOKEN only when GITEA_URL matches the remote
|
|
||||||
# host, and this repo's origin is git.uscllc.com, so that pair can never satisfy
|
|
||||||
# it. Before this fixture the only credential that could reach the authenticated
|
|
||||||
# curl branch was a REAL one — from step 0 on an agent seat, or from step 1
|
|
||||||
# reading the operator's own ~/.config/mosaic/credentials.json. That is why the
|
|
||||||
# "curl success path" case passed: not because the stub credential worked, but
|
|
||||||
# because a production credential was available.
|
|
||||||
#
|
|
||||||
# A fixture is used rather than relying on the sandboxed HOME making step 1 find
|
|
||||||
# nothing: a test that passes because production configuration is ABSENT fails
|
|
||||||
# the moment it is present. Step 1 now resolves deterministically to a value that
|
|
||||||
# is a fixture on every machine.
|
|
||||||
cat > "$CREDENTIALS_FILE" <<'JSON'
|
|
||||||
{
|
|
||||||
"gitea": {
|
|
||||||
"usc": {
|
|
||||||
"url": "https://git.uscllc.com",
|
|
||||||
"token": "stub-token"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
JSON
|
|
||||||
|
|
||||||
cat > "$FIXTURE_DIR/gitea-standard.json" <<'JSON'
|
cat > "$FIXTURE_DIR/gitea-standard.json" <<'JSON'
|
||||||
{
|
{
|
||||||
@@ -187,8 +131,6 @@ run_curl_success_case() {
|
|||||||
set +e
|
set +e
|
||||||
output=$(cd "$REPO_DIR" && \
|
output=$(cd "$REPO_DIR" && \
|
||||||
PATH="$STUB_DIR:$PATH" \
|
PATH="$STUB_DIR:$PATH" \
|
||||||
HOME="$HOME_DIR" \
|
|
||||||
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
|
||||||
TMPDIR="$tmpdir" \
|
TMPDIR="$tmpdir" \
|
||||||
GITEA_TOKEN="stub-token" \
|
GITEA_TOKEN="stub-token" \
|
||||||
GITEA_URL="https://git.example.test" \
|
GITEA_URL="https://git.example.test" \
|
||||||
@@ -228,8 +170,6 @@ run_curl_early_exit_cleanup_case() {
|
|||||||
set +e
|
set +e
|
||||||
output=$(cd "$REPO_DIR" && \
|
output=$(cd "$REPO_DIR" && \
|
||||||
PATH="$STUB_DIR:$PATH" \
|
PATH="$STUB_DIR:$PATH" \
|
||||||
HOME="$HOME_DIR" \
|
|
||||||
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
|
||||||
TMPDIR="$tmpdir" \
|
TMPDIR="$tmpdir" \
|
||||||
GITEA_TOKEN="stub-token" \
|
GITEA_TOKEN="stub-token" \
|
||||||
GITEA_URL="https://git.example.test" \
|
GITEA_URL="https://git.example.test" \
|
||||||
@@ -264,8 +204,7 @@ run_curl_early_exit_cleanup_case() {
|
|||||||
run_case() {
|
run_case() {
|
||||||
local fixture="$1" expected_number="$2" expected_head="$3"
|
local fixture="$1" expected_number="$2" expected_head="$3"
|
||||||
local output
|
local output
|
||||||
output=$(cd "$REPO_DIR" && HOME="$HOME_DIR" MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
output=$(cd "$REPO_DIR" && MOSAIC_GITEA_PR_METADATA_RAW_FILE="$fixture" "$SCRIPT_DIR/pr-metadata.sh" -n "$expected_number")
|
||||||
MOSAIC_GITEA_PR_METADATA_RAW_FILE="$fixture" "$SCRIPT_DIR/pr-metadata.sh" -n "$expected_number")
|
|
||||||
PR_METADATA_OUTPUT="$output" python3 - "$expected_number" "$expected_head" <<'PY'
|
PR_METADATA_OUTPUT="$output" python3 - "$expected_number" "$expected_head" <<'PY'
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
|||||||
@@ -225,10 +225,7 @@ write_response() {
|
|||||||
emit() {
|
emit() {
|
||||||
# Split a two-line "status\n<json body>" python result into the response.
|
# Split a two-line "status\n<json body>" python result into the response.
|
||||||
local result="$1"
|
local result="$1"
|
||||||
response_status="${result%%$'\n'*}"
|
write_response "$(printf '%s' "$result" | head -n1)" "$(printf '%s' "$result" | tail -n +2)"
|
||||||
response_body=""
|
|
||||||
[[ "$result" == *$'\n'* ]] && response_body="${result#*$'\n'}"
|
|
||||||
write_response "$response_status" "$response_body"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
mode="${PR_REVIEW_TEST_MODE:-}"
|
mode="${PR_REVIEW_TEST_MODE:-}"
|
||||||
@@ -439,19 +436,6 @@ elif mode == "comment-url-wrong-repo":
|
|||||||
elif mode == "comment-url-suffix-injection":
|
elif mode == "comment-url-suffix-injection":
|
||||||
# Prefix-injected: a bare endswith("/<slug>/pulls/123") test would ACCEPT it.
|
# Prefix-injected: a bare endswith("/<slug>/pulls/123") test would ACCEPT it.
|
||||||
pr_url = f"{_origin}/deceptive{_slug}/pulls/123"
|
pr_url = f"{_origin}/deceptive{_slug}/pulls/123"
|
||||||
elif mode == "comment-url-wrong-port":
|
|
||||||
# #991 bound: an EXPLICIT non-default port is a different service on the same
|
|
||||||
# host. Relaxing http-vs-https must NOT relax this.
|
|
||||||
pr_url = f"{_p.scheme}://{_p.hostname}:8443{_slug}/pulls/123"
|
|
||||||
elif mode == "comment-url-non-web-scheme":
|
|
||||||
# #991 bound: ONLY http/https collapse; any other scheme stays distinguishing.
|
|
||||||
pr_url = f"ftp://{_p.netloc}{_slug}/pulls/123"
|
|
||||||
elif mode == "comment-url-scheme-downgrade":
|
|
||||||
# #991, and the only URL mode here that must be ACCEPTED. A Gitea whose
|
|
||||||
# ROOT_URL is http:// returns http:// object URLs for a repo reached over
|
|
||||||
# https://. Same host, same path, correct record — a truthful provider
|
|
||||||
# answer about a comment that landed, not a forgery.
|
|
||||||
pr_url = f"http://{_p.netloc}{_slug}/pulls/123"
|
|
||||||
elif mode == "comment-mixed-case-slug":
|
elif mode == "comment-mixed-case-slug":
|
||||||
# #875: EXPECTED_REPO_SLUG is taken verbatim from GITEA_API_BASE and can be
|
# #875: EXPECTED_REPO_SLUG is taken verbatim from GITEA_API_BASE and can be
|
||||||
# mixed-case (e.g. "USC/uconnect"), but Gitea canonicalizes the returned
|
# mixed-case (e.g. "USC/uconnect"), but Gitea canonicalizes the returned
|
||||||
@@ -906,16 +890,11 @@ fi
|
|||||||
assert_no_temp_leak "review-body-reuse"
|
assert_no_temp_leak "review-body-reuse"
|
||||||
|
|
||||||
# Cases 12-15 (#865 Blocker 3): a PR comment whose id/author/body are all correct
|
# Cases 12-15 (#865 Blocker 3): a PR comment whose id/author/body are all correct
|
||||||
# but whose provider-returned pull_request_url does not belong to this PR must
|
# but whose provider-returned pull_request_url is forged must FAIL CLOSED.
|
||||||
# FAIL CLOSED. Verification pins the URL's ORIGIN (scheme-class + host + explicit
|
# Verification pins the URL's ORIGIN (scheme+host+effective-port) and FULL path
|
||||||
# non-default port) and FULL path (deployment prefix + exact owner/repo + kind +
|
# (deployment prefix + exact owner/repo + kind + number); a bare endswith/suffix
|
||||||
# number); a bare endswith/suffix test would wrongly accept the look-alike-host
|
# test would wrongly accept the look-alike-host and prefix-injection variants.
|
||||||
# and prefix-injection variants. comment-url-wrong-port and
|
for bad_mode in comment-url-wrong-host comment-url-wrong-owner comment-url-wrong-repo comment-url-suffix-injection; do
|
||||||
# comment-url-non-web-scheme (#991) bound the scheme relaxation from the other
|
|
||||||
# side: collapsing http/https must not also collapse a different port or a
|
|
||||||
# different scheme family.
|
|
||||||
for bad_mode in comment-url-wrong-host comment-url-wrong-owner comment-url-wrong-repo \
|
|
||||||
comment-url-suffix-injection comment-url-wrong-port comment-url-non-web-scheme; do
|
|
||||||
if run_review "$bad_mode" comment durable-body; then
|
if run_review "$bad_mode" comment durable-body; then
|
||||||
echo "FAIL: forged comment URL ($bad_mode) was accepted" >&2
|
echo "FAIL: forged comment URL ($bad_mode) was accepted" >&2
|
||||||
cat "$OUTPUT_FILE" >&2
|
cat "$OUTPUT_FILE" >&2
|
||||||
@@ -941,19 +920,6 @@ run_review comment-mixed-case-slug comment durable-body https://git.mosaicstack.
|
|||||||
grep -q 'Added and verified comment on Gitea PR #123' "$OUTPUT_FILE"
|
grep -q 'Added and verified comment on Gitea PR #123' "$OUTPUT_FILE"
|
||||||
assert_no_temp_leak "comment-mixed-case-slug"
|
assert_no_temp_leak "comment-mixed-case-slug"
|
||||||
|
|
||||||
# Case 15c (#991): the deployment's Gitea ROOT_URL is http:// while every client
|
|
||||||
# reaches it over https://, so the provider returns an http:// pull_request_url
|
|
||||||
# for a comment that is otherwise entirely correct. Same class as 15b — a
|
|
||||||
# legitimate provider response, not a spoof — and a scheme-strict compare
|
|
||||||
# rejects it on EVERY comment, deterministically. That is not a cosmetic false
|
|
||||||
# negative here: on a host where no seat can create a review OBJECT, this
|
|
||||||
# comment-form record is the only gate-16 evidence obtainable, and the wrapper
|
|
||||||
# refuses all of it while the comment sits durably on the PR. Host, path, owner,
|
|
||||||
# repo, kind and number stay strict; only http-vs-https is relaxed.
|
|
||||||
run_review comment-url-scheme-downgrade comment durable-body
|
|
||||||
grep -q 'Added and verified comment on Gitea PR #123' "$OUTPUT_FILE"
|
|
||||||
assert_no_temp_leak "comment-url-scheme-downgrade"
|
|
||||||
|
|
||||||
# Case 16 (#865 ITEM 1, current-head TOCTOU): the PR head advances between the
|
# Case 16 (#865 ITEM 1, current-head TOCTOU): the PR head advances between the
|
||||||
# pre-submit head read (which pins the review) and the post-verify re-read. The
|
# pre-submit head read (which pins the review) and the post-verify re-read. The
|
||||||
# review is genuinely created and verified as pinned to the OLD head, but the
|
# review is genuinely created and verified as pinned to the OLD head, but the
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user