Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc67590a96 |
@@ -1,104 +0,0 @@
|
|||||||
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,11 +1,7 @@
|
|||||||
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 {
|
import { ProjectBootstrapService } from './project-bootstrap.service.js';
|
||||||
ProjectBootstrapService,
|
|
||||||
type BootstrapProjectResult,
|
|
||||||
} from './project-bootstrap.service.js';
|
|
||||||
import { CreateWorkspaceDto } from './workspace.dto.js';
|
|
||||||
|
|
||||||
@Controller('api/workspaces')
|
@Controller('api/workspaces')
|
||||||
@UseGuards(AuthGuard)
|
@UseGuards(AuthGuard)
|
||||||
@@ -15,14 +11,20 @@ export class WorkspaceController {
|
|||||||
@Post()
|
@Post()
|
||||||
async create(
|
async create(
|
||||||
@CurrentUser() user: { id: string },
|
@CurrentUser() user: { id: string },
|
||||||
@Body() dto: CreateWorkspaceDto,
|
@Body()
|
||||||
): Promise<BootstrapProjectResult> {
|
body: {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
teamId?: string;
|
||||||
|
repoUrl?: string;
|
||||||
|
},
|
||||||
|
) {
|
||||||
return this.bootstrap.bootstrap({
|
return this.bootstrap.bootstrap({
|
||||||
name: dto.name,
|
name: body.name,
|
||||||
description: dto.description,
|
description: body.description,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
teamId: dto.teamId,
|
teamId: body.teamId,
|
||||||
repoUrl: dto.repoUrl,
|
repoUrl: body.repoUrl,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
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,33 +1,11 @@
|
|||||||
import { BadRequestException } from '@nestjs/common';
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
import fs from 'node:fs/promises';
|
|
||||||
import os from 'node:os';
|
|
||||||
import path from 'node:path';
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
import { WorkspaceService } from './workspace.service.js';
|
import { WorkspaceService } from './workspace.service.js';
|
||||||
|
import path from 'node:path';
|
||||||
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();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -98,69 +76,4 @@ 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,30 +1,10 @@
|
|||||||
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
import { 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;
|
||||||
@@ -59,32 +39,14 @@ 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 !== undefined) {
|
if (repoUrl) {
|
||||||
// Clone existing repo. Defense in depth keeps dangerous local helpers
|
// Clone existing repo
|
||||||
// disabled and terminates option parsing before positional arguments.
|
await execFileAsync('git', ['clone', repoUrl, '.'], { cwd: workspacePath });
|
||||||
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
|
||||||
|
|||||||
@@ -10,8 +10,6 @@ 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
|
||||||
@@ -23,22 +21,11 @@ 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 --chown=node:node --from=builder /deploy/node_modules ./node_modules
|
COPY --from=builder /deploy/node_modules ./node_modules
|
||||||
COPY --chown=node:node --from=builder /deploy/package.json ./package.json
|
COPY --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 --chown=node:node --from=builder /app/apps/gateway/dist ./dist
|
COPY --from=builder /app/apps/gateway/dist ./dist
|
||||||
# gateway defaults to port 14242 (apps/gateway/src/main.ts)
|
EXPOSE 4000
|
||||||
EXPOSE 14242
|
|
||||||
USER node
|
|
||||||
CMD ["node", "dist/main.js"]
|
CMD ["node", "dist/main.js"]
|
||||||
|
|||||||
@@ -8,11 +8,9 @@ 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 @mosaicstack/web build
|
RUN pnpm --filter @mosaic/web build
|
||||||
|
|
||||||
FROM base AS runner
|
FROM base AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,114 +0,0 @@
|
|||||||
# 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 D1–D15 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 1–8, 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 D1–D15 binding contract.
|
|
||||||
- No source code, database, deployment, or live runtime changes made.
|
|
||||||
@@ -193,16 +193,19 @@ describe('Unified wizard (runWizard with default skipGateway)', () => {
|
|||||||
'Your timezone': 'UTC',
|
'Your timezone': 'UTC',
|
||||||
});
|
});
|
||||||
|
|
||||||
await runWizard({
|
await expect(
|
||||||
|
runWizard({
|
||||||
mosaicHome: tmpDir,
|
mosaicHome: tmpDir,
|
||||||
sourceDir: tmpDir,
|
sourceDir: tmpDir,
|
||||||
prompter,
|
prompter,
|
||||||
configService: createConfigService(tmpDir, tmpDir),
|
configService: createConfigService(tmpDir, tmpDir),
|
||||||
skipGatewayNpmInstall: true,
|
skipGatewayNpmInstall: true,
|
||||||
});
|
}),
|
||||||
|
).rejects.toThrow('Gateway configuration failed');
|
||||||
|
|
||||||
const logs = prompter.getLogs();
|
const logs = prompter.getLogs();
|
||||||
expect(logs.some((line) => line.includes('Gateway did not become healthy'))).toBe(true);
|
expect(logs.some((line) => line.includes('Gateway did not become healthy'))).toBe(true);
|
||||||
|
expect(logs.some((line) => line.includes('Gateway configuration failed'))).toBe(true);
|
||||||
expect(logs.some((line) => line.includes('Installation Summary'))).toBe(false);
|
expect(logs.some((line) => line.includes('Installation Summary'))).toBe(false);
|
||||||
expect(logs.some((line) => line.includes('Mosaic is ready.'))).toBe(false);
|
expect(logs.some((line) => line.includes('Mosaic is ready.'))).toBe(false);
|
||||||
expect(gatewayConfigMock).toHaveBeenCalledTimes(1);
|
expect(gatewayConfigMock).toHaveBeenCalledTimes(1);
|
||||||
|
|||||||
@@ -68,8 +68,6 @@ export async function quickStartPath(
|
|||||||
|
|
||||||
// Gateway config + bootstrap
|
// Gateway config + bootstrap
|
||||||
if (!options.skipGateway) {
|
if (!options.skipGateway) {
|
||||||
const headlessRun = process.env['MOSAIC_ASSUME_YES'] === '1' || !process.stdin.isTTY;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const configResult = await gatewayConfigStage(prompter, state, {
|
const configResult = await gatewayConfigStage(prompter, state, {
|
||||||
host: options.gatewayHost ?? 'localhost',
|
host: options.gatewayHost ?? 'localhost',
|
||||||
@@ -81,11 +79,9 @@ export async function quickStartPath(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!configResult.ready || !configResult.host || !configResult.port) {
|
if (!configResult.ready || !configResult.host || !configResult.port) {
|
||||||
if (headlessRun) {
|
const message = 'Gateway configuration failed — aborting wizard.';
|
||||||
prompter.warn('Gateway configuration failed in headless mode — aborting wizard.');
|
prompter.warn(message);
|
||||||
process.exit(1);
|
throw new Error(message);
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const bootstrapResult = await gatewayBootstrapStage(prompter, state, {
|
const bootstrapResult = await gatewayBootstrapStage(prompter, state, {
|
||||||
@@ -93,9 +89,9 @@ export async function quickStartPath(
|
|||||||
port: configResult.port,
|
port: configResult.port,
|
||||||
});
|
});
|
||||||
if (!bootstrapResult.completed) {
|
if (!bootstrapResult.completed) {
|
||||||
prompter.warn('Admin bootstrap failed — aborting wizard.');
|
const message = 'Admin bootstrap failed — aborting wizard.';
|
||||||
process.exit(1);
|
prompter.warn(message);
|
||||||
return;
|
throw new Error(message);
|
||||||
}
|
}
|
||||||
finalizeResult.showSummary();
|
finalizeResult.showSummary();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -348,18 +348,21 @@ async function runFinishPath(
|
|||||||
providerType: state.providerType,
|
providerType: state.providerType,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (configResult.ready && configResult.host && configResult.port) {
|
if (!configResult.ready || !configResult.host || !configResult.port) {
|
||||||
|
const message = 'Gateway configuration failed — aborting wizard.';
|
||||||
|
prompter.warn(message);
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
const bootstrapResult = await gatewayBootstrapStage(prompter, state, {
|
const bootstrapResult = await gatewayBootstrapStage(prompter, state, {
|
||||||
host: configResult.host,
|
host: configResult.host,
|
||||||
port: configResult.port,
|
port: configResult.port,
|
||||||
});
|
});
|
||||||
if (!bootstrapResult.completed) {
|
if (!bootstrapResult.completed) {
|
||||||
prompter.warn('Admin bootstrap failed — aborting wizard.');
|
const message = 'Admin bootstrap failed — aborting wizard.';
|
||||||
process.exit(1);
|
prompter.warn(message);
|
||||||
return;
|
throw new Error(message);
|
||||||
}
|
}
|
||||||
finalizeResult.showSummary();
|
finalizeResult.showSummary();
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
prompter.warn(`Gateway setup failed: ${err instanceof Error ? err.message : String(err)}`);
|
prompter.warn(`Gateway setup failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
throw err;
|
throw err;
|
||||||
@@ -420,9 +423,9 @@ async function runHeadlessPath(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!configResult.ready || !configResult.host || !configResult.port) {
|
if (!configResult.ready || !configResult.host || !configResult.port) {
|
||||||
prompter.warn('Gateway configuration failed in headless mode — aborting wizard.');
|
const message = 'Gateway configuration failed in headless mode — aborting wizard.';
|
||||||
process.exit(1);
|
prompter.warn(message);
|
||||||
return;
|
throw new Error(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
const bootstrapResult = await gatewayBootstrapStage(prompter, state, {
|
const bootstrapResult = await gatewayBootstrapStage(prompter, state, {
|
||||||
@@ -430,9 +433,9 @@ async function runHeadlessPath(
|
|||||||
port: configResult.port,
|
port: configResult.port,
|
||||||
});
|
});
|
||||||
if (!bootstrapResult.completed) {
|
if (!bootstrapResult.completed) {
|
||||||
prompter.warn('Admin bootstrap failed — aborting wizard.');
|
const message = 'Admin bootstrap failed — aborting wizard.';
|
||||||
process.exit(1);
|
prompter.warn(message);
|
||||||
return;
|
throw new Error(message);
|
||||||
}
|
}
|
||||||
finalizeResult.showSummary();
|
finalizeResult.showSummary();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -477,18 +480,21 @@ async function runKeepPath(
|
|||||||
skipInstall: options.skipGatewayNpmInstall,
|
skipInstall: options.skipGatewayNpmInstall,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (configResult.ready && configResult.host && configResult.port) {
|
if (!configResult.ready || !configResult.host || !configResult.port) {
|
||||||
|
const message = 'Gateway configuration failed — aborting wizard.';
|
||||||
|
prompter.warn(message);
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
const bootstrapResult = await gatewayBootstrapStage(prompter, state, {
|
const bootstrapResult = await gatewayBootstrapStage(prompter, state, {
|
||||||
host: configResult.host,
|
host: configResult.host,
|
||||||
port: configResult.port,
|
port: configResult.port,
|
||||||
});
|
});
|
||||||
if (!bootstrapResult.completed) {
|
if (!bootstrapResult.completed) {
|
||||||
prompter.warn('Admin bootstrap failed — aborting wizard.');
|
const message = 'Admin bootstrap failed — aborting wizard.';
|
||||||
process.exit(1);
|
prompter.warn(message);
|
||||||
return;
|
throw new Error(message);
|
||||||
}
|
}
|
||||||
finalizeResult.showSummary();
|
finalizeResult.showSummary();
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
prompter.warn(`Gateway setup failed: ${err instanceof Error ? err.message : String(err)}`);
|
prompter.warn(`Gateway setup failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
throw err;
|
throw err;
|
||||||
|
|||||||
@@ -75,16 +75,6 @@ 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}`;
|
||||||
|
|||||||
+4
-1
@@ -762,9 +762,12 @@ if [[ "$FLAG_CHECK" == "false" ]]; then
|
|||||||
if "$MOSAIC_CMD" wizard; then
|
if "$MOSAIC_CMD" wizard; then
|
||||||
ok "Wizard complete."
|
ok "Wizard complete."
|
||||||
else
|
else
|
||||||
warn "Wizard exited non-zero."
|
fail "Wizard failed; installation is incomplete."
|
||||||
|
echo " Completed: framework and CLI installation"
|
||||||
|
echo " Failed: gateway configuration or admin bootstrap"
|
||||||
echo " You can retry with: ${C}mosaic wizard${RESET}"
|
echo " You can retry with: ${C}mosaic wizard${RESET}"
|
||||||
echo " Or run gateway install alone: ${C}mosaic gateway install${RESET}"
|
echo " Or run gateway install alone: ${C}mosaic gateway install${RESET}"
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
|
|||||||
Reference in New Issue
Block a user