Compare commits

..
Author SHA1 Message Date
shaggy (mosaic-dev box) a34e92cf39 fix(gateway): harden workspace repository cloning
ci/woodpecker/pr/ci Pipeline was successful
2026-08-09 20:12:39 -05:00
shaggy (mosaic-dev box)andClaude Fable 5 13cd673d50 fix(docker): gateway runner needs git + MOSAIC_ROOT workspace dir; EXPOSE actual port 14242
ci/woodpecker/pr/ci Pipeline was successful
WorkspaceService shells out to git at runtime and roots workspaces at
$MOSAIC_ROOT/.workspaces — the runner image had no git binary and no
workspace directory. EXPOSE said 4000 but main.ts defaults to 14242.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ESFAnh2t9HmLwng8oW95St
2026-08-09 18:03:42 -05:00
shaggy (mosaic-dev box)andClaude Fable 5 620cc608e0 fix(docker): copy scripts/ into web builder — prepare runs install-hooks.mjs on install
ci/woodpecker/pr/ci Pipeline was successful
The layer-cached install copies only manifests and packages/, so the
root prepare script could not be found and pnpm install exited 1
before the git-absent guard could even run.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ESFAnh2t9HmLwng8oW95St
2026-08-09 18:03:18 -05:00
shaggy (mosaic-dev box)andClaude Fable 5 91e692e3e7 fix: break-C — install-hooks no-ops without git; web image builds @mosaicstack/web
ci/woodpecker/pr/ci Pipeline was canceled
install-hooks.mjs hard-failed (exit 1) in environments without a git
binary — e.g. the docker image builds, which have no git and no repo.
Hook installation is meaningless there; skip with a warning instead.

docker/web.Dockerfile filtered @mosaic/web, but the package is named
@mosaicstack/web, so the image build compiled nothing.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ESFAnh2t9HmLwng8oW95St
2026-08-09 17:58:13 -05:00
velmaandmos-dt-0 24bbd40dc7 docs: WebUI fleet Claude bridge — Task 0 decision plan (#1131)
ci/woodpecker/push/publish Pipeline failed
Docs-only plan PR. FRED_APPROVED_REF=0629361ca39a4dd7fb3e575d11c64bea9e545dae (review 147). Merged by fred (orchestrator) via API: pr-merge.sh policy predates the next lane (main-only hardcode) — wrapper fix tracked separately.

Co-authored-by: Velma <[email protected]>
2026-08-09 10:28:41 +00:00
13 changed files with 2141 additions and 242 deletions
@@ -0,0 +1,104 @@
import 'reflect-metadata';
import {
type CanActivate,
type ExecutionContext,
type INestApplication,
ValidationPipe,
} from '@nestjs/common';
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { AuthGuard } from '../auth/auth.guard.js';
import { ProjectBootstrapService } from './project-bootstrap.service.js';
import { WorkspaceController } from './workspace.controller.js';
const bootstrapMock = vi.fn(() =>
Promise.resolve({
projectId: 'project-1',
workspacePath: '/opt/mosaic/.workspaces/users/user-1/project-1',
}),
);
const authGuard: CanActivate = {
canActivate(context: ExecutionContext): boolean {
const requestContext = context.switchToHttp().getRequest<{ user?: { id: string } }>();
requestContext.user = { id: 'user-1' };
return true;
},
};
describe('POST /api/workspaces repoUrl validation', () => {
let app: INestApplication;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
controllers: [WorkspaceController],
providers: [
{
provide: ProjectBootstrapService,
useValue: { bootstrap: bootstrapMock },
},
],
})
.overrideGuard(AuthGuard)
.useValue(authGuard)
.compile();
app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
await app.init();
await app.getHttpAdapter().getInstance().ready();
});
beforeEach(() => {
bootstrapMock.mockClear();
});
afterAll(async () => {
await app.close();
});
it.each([
['a leading-dash value', '--upload-pack=sh -c id'],
['an ext remote helper', 'ext::sh -c id'],
['a file URL', 'file:///tmp/repository'],
['an unparseable value', 'not a url'],
['an SSH shorthand', '[email protected]:acme/repository.git'],
['a scheme without //', 'https:example.com/acme/repository.git'],
['a hostless git URL', 'git:///tmp/repository'],
])('returns 400 for %s', async (_description, repoUrl) => {
const response = await request(app.getHttpServer())
.post('/api/workspaces')
.send({ name: 'Example', repoUrl })
.set('Content-Type', 'application/json');
expect(response.status).toBe(400);
expect(bootstrapMock).not.toHaveBeenCalled();
});
it.each([
['a plain HTTPS repository URL', 'https://example.com/acme/repository.git'],
['a git protocol repository URL', 'git://example.com/acme/repository.git'],
])('accepts %s', async (_description, repoUrl) => {
const response = await request(app.getHttpServer())
.post('/api/workspaces')
.send({ name: 'Example', repoUrl })
.set('Content-Type', 'application/json');
expect(response.status).toBe(201);
expect(bootstrapMock).toHaveBeenCalledWith({
name: 'Example',
description: undefined,
userId: 'user-1',
teamId: undefined,
repoUrl,
});
});
});
@@ -1,7 +1,11 @@
import { Body, Controller, Post, UseGuards } from '@nestjs/common'; import { Body, Controller, Post, UseGuards } from '@nestjs/common';
import { AuthGuard } from '../auth/auth.guard.js'; import { AuthGuard } from '../auth/auth.guard.js';
import { CurrentUser } from '../auth/current-user.decorator.js'; import { CurrentUser } from '../auth/current-user.decorator.js';
import { ProjectBootstrapService } from './project-bootstrap.service.js'; import {
ProjectBootstrapService,
type BootstrapProjectResult,
} from './project-bootstrap.service.js';
import { CreateWorkspaceDto } from './workspace.dto.js';
@Controller('api/workspaces') @Controller('api/workspaces')
@UseGuards(AuthGuard) @UseGuards(AuthGuard)
@@ -11,20 +15,14 @@ export class WorkspaceController {
@Post() @Post()
async create( async create(
@CurrentUser() user: { id: string }, @CurrentUser() user: { id: string },
@Body() @Body() dto: CreateWorkspaceDto,
body: { ): Promise<BootstrapProjectResult> {
name: string;
description?: string;
teamId?: string;
repoUrl?: string;
},
) {
return this.bootstrap.bootstrap({ return this.bootstrap.bootstrap({
name: body.name, name: dto.name,
description: body.description, description: dto.description,
userId: user.id, userId: user.id,
teamId: body.teamId, teamId: dto.teamId,
repoUrl: body.repoUrl, repoUrl: dto.repoUrl,
}); });
} }
} }
@@ -0,0 +1,33 @@
import { IsOptional, IsString, IsUrl, Matches, MaxLength } from 'class-validator';
export class CreateWorkspaceDto {
@IsString()
@MaxLength(255)
name!: string;
@IsOptional()
@IsString()
@MaxLength(10_000)
description?: string;
@IsOptional()
@IsString()
teamId?: string;
@IsOptional()
@IsString()
@Matches(/^(?:https|git):\/\//i, {
message: 'repoUrl must be a valid https:// or git:// URL',
})
@IsUrl(
{
protocols: ['https', 'git'],
require_host: true,
require_protocol: true,
require_tld: false,
require_valid_protocol: true,
},
{ message: 'repoUrl must be a valid https:// or git:// URL' },
)
repoUrl?: string;
}
@@ -1,11 +1,33 @@
import { describe, it, expect, beforeEach } from 'vitest'; import { BadRequestException } from '@nestjs/common';
import { WorkspaceService } from './workspace.service.js'; import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { WorkspaceService } from './workspace.service.js';
type ExecFileMock = (
command: string,
args: readonly string[],
options: { cwd: string },
callback: (error: Error | null, stdout: string, stderr: string) => void,
) => void;
const { execFileMock } = vi.hoisted(() => ({
execFileMock: vi.fn<ExecFileMock>(),
}));
vi.mock('node:child_process', () => ({
execFile: execFileMock,
}));
describe('WorkspaceService', () => { describe('WorkspaceService', () => {
let service: WorkspaceService; let service: WorkspaceService;
beforeEach(() => { beforeEach(() => {
execFileMock.mockReset();
execFileMock.mockImplementation((_command, _args, _options, callback) => {
callback(null, '', '');
});
service = new WorkspaceService(); service = new WorkspaceService();
}); });
@@ -76,4 +98,69 @@ describe('WorkspaceService', () => {
} }
}); });
}); });
describe('create', () => {
const project = {
id: 'project-1',
ownerType: 'user',
userId: 'user-1',
teamId: null,
} as const;
let originalRoot: string | undefined;
let temporaryRoot: string;
beforeEach(async () => {
originalRoot = process.env['MOSAIC_ROOT'];
temporaryRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'mosaic-workspace-'));
process.env['MOSAIC_ROOT'] = temporaryRoot;
service = new WorkspaceService();
});
afterEach(async () => {
if (originalRoot === undefined) {
delete process.env['MOSAIC_ROOT'];
} else {
process.env['MOSAIC_ROOT'] = originalRoot;
}
await fs.rm(temporaryRoot, { recursive: true, force: true });
});
it.each([
['a leading-dash URL', '--upload-pack=sh -c id'],
['an ext remote helper', 'ext::sh -c id'],
['a file URL', 'file:///tmp/repository'],
['an unparseable value', 'not a url'],
['an SSH shorthand', '[email protected]:acme/repository.git'],
['a scheme without //', 'https:example.com/acme/repository.git'],
['a hostless git URL', 'git:///tmp/repository'],
])('rejects %s before invoking git', async (_description, repoUrl) => {
await expect(service.create(project, repoUrl)).rejects.toBeInstanceOf(BadRequestException);
expect(execFileMock).not.toHaveBeenCalled();
});
it.each([
['an HTTPS URL', 'https://example.com/acme/repository.git'],
['a git protocol URL', 'git://example.com/acme/repository.git'],
])('accepts %s and invokes hardened git clone arguments', async (_description, repoUrl) => {
const workspacePath = await service.create(project, repoUrl);
expect(execFileMock).toHaveBeenCalledOnce();
expect(execFileMock).toHaveBeenCalledWith(
'git',
[
'-c',
'protocol.ext.allow=never',
'-c',
'protocol.file.allow=never',
'clone',
'--',
repoUrl,
'.',
],
{ cwd: workspacePath },
expect.any(Function),
);
});
});
}); });
@@ -1,10 +1,30 @@
import { Injectable, Logger } from '@nestjs/common'; import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
import { execFile } from 'node:child_process'; import { execFile } from 'node:child_process';
import { promisify } from 'node:util'; import { promisify } from 'node:util';
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
const allowedRepositoryProtocols = new Set(['https:', 'git:']);
const repositoryUrlPrefixPattern = /^(?:https|git):\/\//i;
const repositoryUrlError = 'repoUrl must be a valid https:// or git:// URL';
function assertAllowedRepositoryUrl(repoUrl: string): void {
if (repoUrl.startsWith('-') || !repositoryUrlPrefixPattern.test(repoUrl)) {
throw new BadRequestException(repositoryUrlError);
}
let parsedUrl: URL;
try {
parsedUrl = new URL(repoUrl);
} catch {
throw new BadRequestException(repositoryUrlError);
}
if (!allowedRepositoryProtocols.has(parsedUrl.protocol) || parsedUrl.hostname.length === 0) {
throw new BadRequestException(repositoryUrlError);
}
}
export interface WorkspaceProject { export interface WorkspaceProject {
id: string; id: string;
@@ -39,14 +59,32 @@ export class WorkspaceService {
* If repoUrl is provided, clone instead of init. * If repoUrl is provided, clone instead of init.
*/ */
async create(project: WorkspaceProject, repoUrl?: string): Promise<string> { async create(project: WorkspaceProject, repoUrl?: string): Promise<string> {
if (repoUrl !== undefined) {
assertAllowedRepositoryUrl(repoUrl);
}
const workspacePath = this.resolvePath(project); const workspacePath = this.resolvePath(project);
// Create directory // Create directory
await fs.mkdir(workspacePath, { recursive: true }); await fs.mkdir(workspacePath, { recursive: true });
if (repoUrl) { if (repoUrl !== undefined) {
// Clone existing repo // Clone existing repo. Defense in depth keeps dangerous local helpers
await execFileAsync('git', ['clone', repoUrl, '.'], { cwd: workspacePath }); // disabled and terminates option parsing before positional arguments.
await execFileAsync(
'git',
[
'-c',
'protocol.ext.allow=never',
'-c',
'protocol.file.allow=never',
'clone',
'--',
repoUrl,
'.',
],
{ cwd: workspacePath },
);
this.logger.log(`Cloned ${repoUrl} into workspace ${workspacePath}`); this.logger.log(`Cloned ${repoUrl} into workspace ${workspacePath}`);
} else { } else {
// Init new git repo // Init new git repo
+17 -4
View File
@@ -10,6 +10,8 @@ COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
COPY apps/gateway/package.json ./apps/gateway/ COPY apps/gateway/package.json ./apps/gateway/
COPY packages/ ./packages/ COPY packages/ ./packages/
COPY plugins/ ./plugins/ COPY plugins/ ./plugins/
# the root prepare script runs scripts/install-hooks.mjs on install
COPY scripts/ ./scripts/
RUN pnpm install --frozen-lockfile RUN pnpm install --frozen-lockfile
COPY . . COPY . .
# Build gateway and all of its workspace dependencies via turbo dependency graph # Build gateway and all of its workspace dependencies via turbo dependency graph
@@ -21,11 +23,22 @@ RUN pnpm --filter @mosaicstack/gateway --prod deploy --legacy /deploy
FROM base AS runner FROM base AS runner
WORKDIR /app WORKDIR /app
ENV NODE_ENV=production ENV NODE_ENV=production
# WorkspaceService shells out to git at runtime and roots workspaces at
# $MOSAIC_ROOT/.workspaces (apps/gateway/src/workspace/workspace.service.ts);
# mount a volume over /opt/mosaic to persist workspaces across container restarts.
# Intentionally unpinned: Alpine's signed repository is the trust anchor; pinning
# git was declined so routine base-image security updates remain maintainable.
RUN apk add --no-cache git \
&& mkdir -p /opt/mosaic/.workspaces \
&& chown -R node:node /opt/mosaic /app
ENV MOSAIC_ROOT=/opt/mosaic
# Use the pnpm deploy output — resolves all deps into a flat, self-contained node_modules # Use the pnpm deploy output — resolves all deps into a flat, self-contained node_modules
COPY --from=builder /deploy/node_modules ./node_modules COPY --chown=node:node --from=builder /deploy/node_modules ./node_modules
COPY --from=builder /deploy/package.json ./package.json COPY --chown=node:node --from=builder /deploy/package.json ./package.json
# dist is declared in package.json "files" so pnpm deploy copies it into /deploy; # dist is declared in package.json "files" so pnpm deploy copies it into /deploy;
# copy from builder explicitly as belt-and-suspenders # copy from builder explicitly as belt-and-suspenders
COPY --from=builder /app/apps/gateway/dist ./dist COPY --chown=node:node --from=builder /app/apps/gateway/dist ./dist
EXPOSE 4000 # gateway defaults to port 14242 (apps/gateway/src/main.ts)
EXPOSE 14242
USER node
CMD ["node", "dist/main.js"] CMD ["node", "dist/main.js"]
+3 -1
View File
@@ -8,9 +8,11 @@ WORKDIR /app
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./ COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
COPY apps/web/package.json ./apps/web/ COPY apps/web/package.json ./apps/web/
COPY packages/ ./packages/ COPY packages/ ./packages/
# the root prepare script runs scripts/install-hooks.mjs on install
COPY scripts/ ./scripts/
RUN pnpm install --frozen-lockfile RUN pnpm install --frozen-lockfile
COPY . . COPY . .
RUN pnpm --filter @mosaic/web build RUN pnpm --filter @mosaicstack/web build
FROM base AS runner FROM base AS runner
WORKDIR /app WORKDIR /app
File diff suppressed because it is too large Load Diff
+114
View File
@@ -0,0 +1,114 @@
# WebUI Fleet Bridge Planning Scratchpad
**Mode:** Task 0 docs-only decision PR authorized; Task 1 and all executable/runtime work remain blocked.
**Owner:** Velma
**Opened:** 2026-08-09
**Scope:** One enrolled Agent Host launching one native `mosaic claude` OAuth session and streaming it into `apps/web` through `apps/gateway`.
## Objective
Turn the approved direction in `jarvis-brain/docs/scratchpads/MOSAIC-WEBUI-FLEET-BRIDGE.md` into a test-first implementation plan while preserving Fred's harness-home/launcher contract and Scooby's greenfield safety findings.
## Source reconciliation
- Current `origin/main`: `b0f7d26dd9c14d91eaaefc35d6c9fd6618a0bd92`.
- Current `origin/next`: `4df478cdd150fdf8d52ea109f02ade5d85017acd`.
- Branches currently diverge (`main` has 11 unique commits; `next` has 13). `next` contains local-tier Redis fix #689; `main` contains later fleet/shell fixes.
- Fred's three-root harness-home design and promotion stack are not yet fully present on either baseline.
- Therefore neither current SHA is an acceptable implementation pin. Code may begin only from a Fred-certified reconciled SHA containing the required launcher/home contract and safe Gateway startup prerequisites.
- The deployed `mosaic.woltje.com` v0.0.20 UI remains reference-only.
## Verified seams
- Current browser chat uses an in-process Pi SDK session.
- `AgentRuntimeProvider` supports list/tree/stream/send/attach/detach/terminate, but not create/start.
- `InteractionController` enrolls an already-existing runtime session; it cannot launch one.
- Hermes is the only runtime provider registered in Gateway.
- Tmux streaming is explicitly unsupported and remains out of scope.
- `mosaic claude` is the authoritative launcher and accepts Claude's machine-facing stream-json flags.
- Installed discovery version: Claude Code 2.1.226. Target Distrobox version must be independently pinned and certified.
## Non-negotiable dependencies
1. Fred approves the machine-facing launcher/seat-home contract before code.
2. No WebUI/Gateway direct read of lease broker state, daemon socket, or state files (F-V3).
3. No provider OAuth token leaves the Agent Host.
4. No local Gateway/Web startup around the KBN/database hold.
5. Greenfield work runs in a Debian Distrobox with an isolated home.
6. The initial plan PR targets `next`; Fred binds D1D15 on its exact head before issue/PRD/tracker completion or implementation work.
## Reproducible evidence
Run from a clean Stack clone:
```bash
git fetch origin main next
git rev-parse origin/main origin/next
git rev-list --left-right --count origin/main...origin/next
rg -n "interface AgentRuntimeProvider|createSession|streamEvents|terminate" \
packages/types/src/agent packages/agent/src apps/gateway/src/agent
rg -n "AgentService\.prompt|interaction_sessions|createRuntimeTerminationApproval" \
apps/gateway/src packages/db/src/schema.ts
```
Primary inspected source seams:
- `packages/types/src/agent/agent-runtime-provider.ts`
- `packages/agent/src/{runtime-provider-registry,hermes-runtime-provider,matrix-native-runtime-provider,tmux-fleet-runtime-provider}.ts`
- `apps/gateway/src/agent/{runtime-provider-registry.service,interaction.controller,durable-session.repository,durable-session.service}.ts`
- `apps/gateway/src/chat/chat.gateway.ts`
- `packages/mosaic/src/commands/{launch,interaction}.ts`
- `packages/mosaic/src/fleet/generated-env-boundary.ts`
- `packages/db/src/schema.ts`
- `apps/web/src/app/(dashboard)/chat/page.tsx`
Planning-only investigation transcripts are local and intentionally uncommitted:
- `/tmp/velma-plan-stack-surface.txt`
- `/tmp/velma-plan-structure.txt`
- `/tmp/velma-plan-scooby.txt`
- `/tmp/velma-plan-runtime-contract.txt`
## Source findings that constrain the design
- `interaction_sessions.id` is the stable primary key; there are no create/policy/enrollment/state columns.
- `interaction_outbox` has a unique `(session_id, idempotency_key)` index and only `pending | processing | delivered`.
- Baseline `DurableSessionRepository.create()` can replace provider/runtime identity for the same owner; M1 must remove that implicit mutation.
- Baseline termination approval is Redis-backed and currently consumes separately from PostgreSQL; M1 therefore needs durable authorization acceptance before destructive token deletion/dispatch.
- Existing interaction HTTP base is `/api/interaction/:agentName`; the plan extends it rather than inventing a second route family.
- Baseline `launch.ts`/lease launcher still use ambient lookup/literal interpreters. Section 4.1 is non-binding consumer input to Fred's W-F design; W-F's final resolved-launch contract must exist in the certified base before Velma can certify it.
- Root `pnpm test` is not KBN-safe: it includes PGlite migration and framework-shell/lease-broker suites.
## Independent draft review
Seven adversarial review rounds found and drove explicit fixes for:
- a candidate resolved-seat consumer descriptor and threat model, now explicitly non-binding input to Fred's W-F-owned launcher design;
- one active launch per stable conversation, exact pending/failed encodings, durable pre-dispatch reservation, CAS activation, and crash lookup without a migration;
- generation-bound enrollment, command/event revalidation, `SIGHUP` config epochs, and stale-epoch rejection;
- Redis/PostgreSQL exact-stop crash safety via non-destructive verify, durable authorization acceptance, atomic claim/`GETDEL`, and same-operation status reconciliation;
- sequence gaps/reorder limits, deterministic UUIDv5 completion, and Gateway restart fail-closed behavior;
- shared streaming redaction before host ring/transport and again before Gateway persistence/browser;
- D2/D12-selected path-free provenance and a separate safe browser DTO—never raw or hashed path strings;
- exact F-V3 boundary: bridge has no broker API, while Fred's sealed launcher may enforce broker policy internally;
- migration-free focused tests in Tasks 18, with live OAuth, repository transaction, Gateway/Web, and Playwright restricted to the Fred/Scooby-certified Task 9 path;
- candidate private/public commitment and artifact-binding mechanisms that W-F may accept, simplify, replace, or defer;
- JCS event-digest recomputation and equal/different duplicate handling in both accepted and future-buffer states;
- a safe browser presentation DTO for host/workspace/seat/persona labels, readiness, and connection state;
- explicit `OnApplicationBootstrap` create/stop recovery enumeration with no auto-launch;
- all POSIX/Windows/UNC/file/tilde path classes in the streaming redactor and definitive failed-stop response semantics;
- the full 14-column canonical task schema, fake-only Task 5 repository tests, migration-free certified Task 9 DB test, server-owned operation correlations/routes, and exact merged-SHA smoke;
- per-commit independent review, queue guards, exact-head PR review, squash merge, exact merged-next SHA/CI wait, worktree-bound smoke, issue-state readback, and reviewed tracker-closure PR with its own merged-next CI;
- a private DB URL loader captured/exported per session without recording or echoing the credential;
- an attached Task 9 implementation branch, provider-filtered exact issue-state readback, and machine-verified smoke JSON binding source/worktree/deployed SHA;
- a capped, duplicate-key-rejecting, exact-key/type smoke schema so report extensions or JSON boolean/float coercion cannot smuggle data or fake child-count evidence.
The plan remains deliberately **decision-PR-ready, not implementation-ready**. Fred authorized only Task 0's initial two-document PR on `next`. He must still return every Section 2 value, replace all `[FRED-GATE]` entries, certify storage/startup, and provide the W-F-dependent `IMPLEMENTATION_BASE_SHA` before Task 0 closes or Task 1 starts.
## Current status
- Gitea principal verified as `velma`; helper and API wrapper resolution are fail-closed and correct.
- Fred authorized the initial Task 0 docs-only PR in `comms/20260809T094952Z__from-fred__ec0e85.md` and confirmed its `next` lane/W-F descriptor corrections in `comms/20260809T095437Z__from-fred__4ee79d.md`.
- Provisional decisions: D1/D8/D9/D10/D13/D14 approved; D4 tool labels exactly `{Read, Grep, Glob}`; D5/D12 provisional; D11 remains a single-operator seam; D2/D3/D6/D7 and `IMPLEMENTATION_BASE_SHA` are `BLOCKED-ON-W-F`.
- Awaiting exact plan-PR-head review and Fred's complete D1D15 binding contract.
- No source code, database, deployment, or live runtime changes made.
@@ -85,6 +85,10 @@ function Get-QueueState {
$state = "$($Payload.state)".ToLowerInvariant() $state = "$($Payload.state)".ToLowerInvariant()
} }
if ($pending -contains $state) { return "pending" }
if ($failure -contains $state) { return "terminal-failure" }
if ($success -contains $state) { return "terminal-success" }
$values = @() $values = @()
$statuses = @() $statuses = @()
if ($null -ne $Payload.statuses) { $statuses = @($Payload.statuses) } if ($null -ne $Payload.statuses) { $statuses = @($Payload.statuses) }
@@ -97,15 +101,7 @@ function Get-QueueState {
if (-not [string]::IsNullOrEmpty($v)) { $values += $v } if (-not [string]::IsNullOrEmpty($v)) { $values += $v }
} }
# Zero contexts is classified FIRST: Gitea reports a synthetic aggregate if ($values.Count -eq 0 -and [string]::IsNullOrEmpty($state)) { return "no-status" }
# state of "pending" alongside statuses:null / total_count:0 (a commit
# with no CI at all), and honoring that aggregate would poll to the
# timeout. With zero contexts there is nothing to wait on.
if ($values.Count -eq 0) { return "no-status" }
if ($pending -contains $state) { return "pending" }
if ($failure -contains $state) { return "terminal-failure" }
if ($success -contains $state) { return "terminal-success" }
if (($values | Where-Object { $pending -contains $_ }).Count -gt 0) { return "pending" } if (($values | Where-Object { $pending -contains $_ }).Count -gt 0) { return "pending" }
if (($values | Where-Object { $failure -contains $_ }).Count -gt 0) { return "terminal-failure" } if (($values | Where-Object { $failure -contains $_ }).Count -gt 0) { return "terminal-failure" }
if ($values.Count -gt 0 -and ($values | Where-Object { -not ($success -contains $_) }).Count -eq 0) { return "terminal-success" } if ($values.Count -gt 0 -and ($values | Where-Object { -not ($success -contains $_) }).Count -eq 0) { return "terminal-success" }
@@ -52,11 +52,7 @@ except Exception:
print("malformed") print("malformed")
raise SystemExit(0) raise SystemExit(0)
# Gitea returns "statuses": null (not []) for a commit with zero status
# contexts -- e.g. any repo with no CI configured. Treat null as empty.
raw_statuses = payload.get("statuses", []) raw_statuses = payload.get("statuses", [])
if raw_statuses is None:
raw_statuses = []
raw_state = payload.get("state", "") raw_state = payload.get("state", "")
if not isinstance(raw_statuses, list) or not isinstance(raw_state, str): if not isinstance(raw_statuses, list) or not isinstance(raw_state, str):
print("malformed") print("malformed")
@@ -79,18 +75,14 @@ for item in statuses:
raise SystemExit(0) raise SystemExit(0)
values.append(raw_value.lower()) values.append(raw_value.lower())
# Zero contexts is classified FIRST: Gitea reports a synthetic aggregate if any(value in pending_values for value in values) or state in pending_values:
# state of "pending" alongside total_count:0, and an aggregate with no
# contexts behind it must not read as an in-flight pipeline (it would poll
# to the timeout). With zero contexts there is nothing to wait on.
if not values:
print("no-status")
elif any(value in pending_values for value in values) or state in pending_values:
print("pending") print("pending")
elif any(value in failure_values for value in values) or state in failure_values: elif any(value in failure_values for value in values) or state in failure_values:
print("terminal-failure") print("terminal-failure")
elif all(value in success_values for value in values) and state in {"", "success"}: elif values and all(value in success_values for value in values) and state in {"", "success"}:
print("terminal-success") print("terminal-success")
elif not values:
print("no-status")
else: else:
print("unknown") print("unknown")
' '
@@ -473,18 +465,9 @@ while true; do
no-status) no-status)
if [[ "$REQUIRE_STATUS" -eq 1 ]]; then if [[ "$REQUIRE_STATUS" -eq 1 ]]; then
echo "Error: ASSERTED_NOT_READY state=no-status; --require-status was set for ${BRANCH}." >&2 echo "Error: ASSERTED_NOT_READY state=no-status; --require-status was set for ${BRANCH}." >&2
exit 3 else
fi
# A head with zero status contexts has no CI queue to wait on.
# For push, that is queue-clear (a repo with no CI must remain
# pushable) -- mirroring record_cannot_assert's dispositions
# (push=degraded-pass, merge=hold). Merge stays fail-closed:
# no-status there may just mean CI has not reported yet.
if [[ "$PURPOSE" == "push" ]]; then
echo "[ci-queue-wait] no status contexts on ${BRANCH} head (state=no-status) — no CI queue to wait on; queue clear."
exit 0
fi
echo "Error: ASSERTED_NOT_READY state=no-status purpose=${PURPOSE} branch=${BRANCH}." >&2 echo "Error: ASSERTED_NOT_READY state=no-status purpose=${PURPOSE} branch=${BRANCH}." >&2
fi
exit 3 exit 3
;; ;;
terminal-failure|malformed|unknown) terminal-failure|malformed|unknown)
@@ -1,186 +0,0 @@
#!/usr/bin/env bash
# Regression harness for ci-queue-wait.sh's zero-status-context handling.
#
# Gitea's combined-status endpoint returns, for a commit with NO status
# contexts (e.g. a repo with no CI configured at all):
# {"state":"pending","sha":"...","total_count":0,"statuses":null,...}
# -- statuses is JSON null (not []), and state is a synthetic "pending"
# even though nothing is running. Captured live from a Gitea 1.22 host,
# 2026-08-09 (jason.woltje/jarvis-brain, a repo with no pipeline).
#
# Before the fix, `payload.get("statuses", [])` received null, failed the
# isinstance(list) check, and the guard reported ASSERTED_NOT_READY
# state=malformed (exit 3) -- blocking every push to a CI-less repo. Had
# null been tolerated, the synthetic aggregate "pending" would instead
# have polled to the timeout (exit 124). The fix must:
# 1. treat statuses:null as an empty list, and
# 2. classify zero status VALUES as "no-status" regardless of the
# synthetic aggregate state, and
# 3. treat no-status for --purpose push (without --require-status) as
# queue-clear (exit 0) -- a repo with no CI has no queue to wait on;
# this mirrors record_cannot_assert's disposition table
# (push=degraded-pass, merge=hold).
#
# Covers:
# (a) statuses:null + synthetic state:pending, purpose=push
# -> exit 0, queue-clear/no-status message (THE live fault).
# (b) same payload, purpose=merge -> still fail-closed (exit 3).
# (c) same payload, push + --require-status -> still fail-closed (exit 3).
# (d) statuses:[] + state:"" -> same as (a) (exit 0).
# (e) a real pending context -> still polls (times out, 124),
# proving the relaxation didn't swallow genuine pending states.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/ci-queue-wait-no-status}"
REPO_DIR="$WORK_DIR/repo"
STUB_DIR="$WORK_DIR/stubs"
rm -rf "$WORK_DIR"
mkdir -p "$REPO_DIR" "$STUB_DIR"
git -C "$REPO_DIR" init -q
git -C "$REPO_DIR" remote add origin https://git.example.test/acme/widgets.git
# Minimal curl stub (same conventions as test-ci-queue-wait-branch-absent.sh):
# branch lookup answers 200 with a fixed SHA; the status endpoint's payload
# is selected by MOSAIC_STUB_STATUS_MODE.
cat > "$STUB_DIR/curl" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
has_w=0
url=""
for arg in "$@"; do
case "$arg" in
-w) has_w=1 ;;
http://*|https://*) url="$arg" ;;
esac
done
case "$url" in
*/branches/*)
body='{"commit":{"id":"deadbeefcafef00d0123456789abcdef01234567"}}'
if [[ "$has_w" == 1 ]]; then
printf '%s\n200' "$body"
else
printf '%s' "$body"
fi
exit 0
;;
*/status)
mode="${MOSAIC_STUB_STATUS_MODE:?MOSAIC_STUB_STATUS_MODE not set}"
;;
*)
echo "curl stub: unrecognized URL: $url" >&2
exit 2
;;
esac
case "$mode" in
# Verbatim shape of the live Gitea response (repository object elided).
gitea-null-statuses)
body='{"state":"pending","sha":"deadbeefcafef00d0123456789abcdef01234567","total_count":0,"statuses":null,"url":"","commit_url":""}'
;;
empty-statuses)
body='{"state":"","statuses":[]}'
;;
real-pending)
body='{"state":"pending","statuses":[{"context":"ci/woodpecker","status":"running","target_url":""}]}'
;;
*)
echo "curl stub: unknown mode=$mode" >&2
exit 2
;;
esac
printf '%s' "$body"
SH
chmod +x "$STUB_DIR/curl"
run_ci_queue_wait() {
(
cd "$REPO_DIR"
export PATH="$STUB_DIR:$PATH"
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
export MOSAIC_CI_QUEUE_AUDIT_LOG="$WORK_DIR/audit.jsonl"
export GITEA_TOKEN="stub-token"
export GITEA_URL="https://git.example.test"
"$SCRIPT_DIR/ci-queue-wait.sh" -B main -t 3 -i 1 "$@"
)
}
fail=0
# (a) THE live fault: statuses:null + synthetic pending, purpose=push -> exit 0.
set +e
out_a=$(MOSAIC_STUB_STATUS_MODE=gitea-null-statuses run_ci_queue_wait --purpose push 2>&1)
status_a=$?
set -e
if [[ "$status_a" -ne 0 ]]; then
echo "FAIL(a): expected exit 0 for null-statuses/no-CI repo on push, got $status_a" >&2
echo "$out_a" >&2
fail=1
elif [[ "$out_a" == *"malformed"* ]]; then
echo "FAIL(a): null statuses must not be classified as malformed" >&2
echo "$out_a" >&2
fail=1
elif [[ "$out_a" != *"no-status"* ]]; then
echo "FAIL(a): expected a no-status/queue-clear message, got:" >&2
echo "$out_a" >&2
fail=1
fi
# (b) Same payload, purpose=merge -> hold, fail-closed exit 3.
set +e
out_b=$(MOSAIC_STUB_STATUS_MODE=gitea-null-statuses run_ci_queue_wait --purpose merge 2>&1)
status_b=$?
set -e
if [[ "$status_b" -ne 3 ]]; then
echo "FAIL(b): expected exit 3 for no-status on merge, got $status_b" >&2
echo "$out_b" >&2
fail=1
fi
# (c) Same payload, push + --require-status -> strictness opt-in still fails.
set +e
out_c=$(MOSAIC_STUB_STATUS_MODE=gitea-null-statuses run_ci_queue_wait --purpose push --require-status 2>&1)
status_c=$?
set -e
if [[ "$status_c" -ne 3 ]]; then
echo "FAIL(c): expected exit 3 for --require-status with no contexts, got $status_c" >&2
echo "$out_c" >&2
fail=1
fi
# (d) statuses:[] + state:"" (the shape the code always tolerated) -> exit 0 on push.
set +e
out_d=$(MOSAIC_STUB_STATUS_MODE=empty-statuses run_ci_queue_wait --purpose push 2>&1)
status_d=$?
set -e
if [[ "$status_d" -ne 0 ]]; then
echo "FAIL(d): expected exit 0 for empty-statuses on push, got $status_d" >&2
echo "$out_d" >&2
fail=1
fi
# (e) A REAL pending context must still block: polls to timeout, exit 124.
set +e
out_e=$(MOSAIC_STUB_STATUS_MODE=real-pending run_ci_queue_wait --purpose push 2>&1)
status_e=$?
set -e
if [[ "$status_e" -ne 124 ]]; then
echo "FAIL(e): expected exit 124 (timeout) for a genuinely pending context, got $status_e" >&2
echo "$out_e" >&2
fail=1
elif [[ "$out_e" != *"ci/woodpecker=running"* ]]; then
echo "FAIL(e): expected the pending context to be reported, got:" >&2
echo "$out_e" >&2
fail=1
fi
if [[ "$fail" -eq 0 ]]; then
echo "ci-queue-wait no-status regression passed (5/5 cases)"
fi
exit "$fail"
+10
View File
@@ -75,6 +75,16 @@ export async function installHooks({
} = {}) { } = {}) {
if (disabled) return; if (disabled) return;
try {
await execFileAsync('git', ['--version']);
} catch (error) {
if (error.code === 'ENOENT') {
console.warn('git not found; skipping hook installation');
return;
}
throw error;
}
const huskyDir = path.join(root, '.husky'); const huskyDir = path.join(root, '.husky');
const active = path.join(huskyDir, '_'); const active = path.join(huskyDir, '_');
const nonce = `${Date.now()}-${process.pid}`; const nonce = `${Date.now()}-${process.pid}`;