Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
501112d2a4 | ||
|
|
3538d33106 | ||
|
|
49b7943420 | ||
|
|
19e16bd44f | ||
|
|
3bd490c080 | ||
|
|
4b448109dd | ||
|
|
bc1149c15e | ||
|
|
089953a7cf | ||
|
|
7b25be22e9 | ||
|
|
4e3d179e61 | ||
|
|
ae58482b72 | ||
|
|
b2d40dada0 | ||
|
|
d30a4cce00 | ||
|
|
4cd280e48d | ||
|
|
8738a03893 | ||
|
|
04a01be992 | ||
|
|
812e2df1da | ||
|
|
8c292fb32f | ||
|
|
f45928c311 |
+6
-4
@@ -38,10 +38,12 @@ when:
|
||||
- event: push
|
||||
branch: main
|
||||
|
||||
# Turbo remote cache (turbo.mosaicstack.dev) is configured via Woodpecker
|
||||
# repository-level environment variables (TURBO_API, TURBO_TEAM, TURBO_TOKEN).
|
||||
# This avoids from_secret which is blocked on pull_request events.
|
||||
# If the env vars aren't set, turbo falls back to local cache only.
|
||||
# Turbo remote cache (turbo.mosaicstack.dev) is wired in publish.yml via the
|
||||
# org-level Woodpecker secret `turbo_token` (events: push/tag/cron/manual/
|
||||
# deployment — never pull_request). This PR pipeline deliberately gets no
|
||||
# remote-cache credentials: an untrusted PR must not be able to write to (or
|
||||
# poison) the shared cache. Without TURBO_* env vars turbo falls back to
|
||||
# local cache only, which is the intended behavior here.
|
||||
|
||||
steps:
|
||||
install:
|
||||
|
||||
+41
-14
@@ -32,6 +32,11 @@ variables:
|
||||
# non-excluded change still builds, so no transitive dep can silently go stale.
|
||||
# (Woodpecker: `when` entries are OR'd; `path` applies to push/PR only — hence
|
||||
# the separate `event: tag` entry.)
|
||||
# #1407: ONE shared anchor for all three image steps. A second main-only
|
||||
# anchor previously gated build-web/build-appservice, so next-lane pushes
|
||||
# published gateway sha images with no web/appservice counterpart — no
|
||||
# sha-parity set existed for next-lane containerized deploys. Every image
|
||||
# step now builds on next too (sha-only destinations, enforced per step).
|
||||
- &image_build_when
|
||||
- event: tag
|
||||
- event: [push, manual]
|
||||
@@ -44,16 +49,6 @@ variables:
|
||||
- '.woodpecker/**'
|
||||
- event: [push, manual]
|
||||
branch: next
|
||||
- &main_image_build_when
|
||||
- event: tag
|
||||
- event: [push, manual]
|
||||
branch: main
|
||||
path:
|
||||
exclude:
|
||||
- 'packages/mosaic/**'
|
||||
- 'docs/**'
|
||||
- '**/*.md'
|
||||
- '.woodpecker/**'
|
||||
|
||||
when:
|
||||
- branch: [main, next]
|
||||
@@ -73,6 +68,13 @@ steps:
|
||||
# being empty) and on any incomplete verification.
|
||||
verify:
|
||||
image: *node_image
|
||||
environment:
|
||||
# Turbo remote cache (see .woodpecker/ci.yml header comment): org-level
|
||||
# secret, exposed only on trusted events (push/tag/cron/manual/deployment).
|
||||
TURBO_API: https://turbo.mosaicstack.dev
|
||||
TURBO_TEAM: mosaic
|
||||
TURBO_TOKEN:
|
||||
from_secret: turbo_token
|
||||
commands:
|
||||
- *enable_pnpm
|
||||
# (a) Commit identity: the provider's claimed SHA must equal the actual
|
||||
@@ -108,6 +110,13 @@ steps:
|
||||
|
||||
build:
|
||||
image: *node_image
|
||||
environment:
|
||||
# Turbo remote cache (see .woodpecker/ci.yml header comment): org-level
|
||||
# secret, exposed only on trusted events (push/tag/cron/manual/deployment).
|
||||
TURBO_API: https://turbo.mosaicstack.dev
|
||||
TURBO_TEAM: mosaic
|
||||
TURBO_TOKEN:
|
||||
from_secret: turbo_token
|
||||
commands:
|
||||
- *enable_pnpm
|
||||
- pnpm build
|
||||
@@ -460,7 +469,7 @@ steps:
|
||||
|
||||
build-appservice:
|
||||
image: gcr.io/kaniko-project/executor:debug
|
||||
when: *main_image_build_when
|
||||
when: *image_build_when
|
||||
environment:
|
||||
REGISTRY_USER:
|
||||
from_secret: REGISTRY_USERNAME
|
||||
@@ -474,8 +483,17 @@ steps:
|
||||
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
|
||||
- |
|
||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/appservice:sha-${CI_COMMIT_SHA:0:7}"
|
||||
if [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||
if [ "$CI_COMMIT_BRANCH" = "next" ]; then
|
||||
if [ -n "$CI_COMMIT_TAG" ]; then
|
||||
echo "[publish] FATAL: next appservice publish must be sha-only; refusing tag '$CI_COMMIT_TAG'" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "[publish] next appservice publish is sha-only"
|
||||
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/appservice:latest"
|
||||
elif [ -z "$CI_COMMIT_TAG" ]; then
|
||||
echo "[publish] FATAL: appservice image publish may only run for main, next, or tag events" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -n "$CI_COMMIT_TAG" ]; then
|
||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/appservice:$CI_COMMIT_TAG"
|
||||
@@ -495,7 +513,7 @@ steps:
|
||||
|
||||
build-web:
|
||||
image: gcr.io/kaniko-project/executor:debug
|
||||
when: *main_image_build_when
|
||||
when: *image_build_when
|
||||
environment:
|
||||
REGISTRY_USER:
|
||||
from_secret: REGISTRY_USERNAME
|
||||
@@ -509,8 +527,17 @@ steps:
|
||||
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
|
||||
- |
|
||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/web:sha-${CI_COMMIT_SHA:0:7}"
|
||||
if [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||
if [ "$CI_COMMIT_BRANCH" = "next" ]; then
|
||||
if [ -n "$CI_COMMIT_TAG" ]; then
|
||||
echo "[publish] FATAL: next web publish must be sha-only; refusing tag '$CI_COMMIT_TAG'" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "[publish] next web publish is sha-only"
|
||||
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/web:latest"
|
||||
elif [ -z "$CI_COMMIT_TAG" ]; then
|
||||
echo "[publish] FATAL: web image publish may only run for main, next, or tag events" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -n "$CI_COMMIT_TAG" ]; then
|
||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/web:$CI_COMMIT_TAG"
|
||||
|
||||
@@ -14,10 +14,16 @@ import { mountMcpHandler } from './mcp/mcp.controller.js';
|
||||
import { McpService } from './mcp/mcp.service.js';
|
||||
import { detectAndAssertTier, TierDetectionError } from '@mosaicstack/storage';
|
||||
import { resolveGatewayConfigPath } from './env.js';
|
||||
import { assertValidationPipeSeesDtoDecorators } from './validation-pipe-check.js';
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
const logger = new Logger('Bootstrap');
|
||||
|
||||
// Fail loud BEFORE anything else if the global ValidationPipe cannot see
|
||||
// the guarded DTOs' decorated properties (#1391): a broken metatype turns
|
||||
// every request body into a 400 at first use; this surfaces it at boot.
|
||||
assertValidationPipeSeesDtoDecorators();
|
||||
|
||||
if (!process.env['BETTER_AUTH_SECRET']) {
|
||||
throw new Error('BETTER_AUTH_SECRET is required');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Boot-time ValidationPipe metatype self-check (#1391).
|
||||
*
|
||||
* The check exists to fail loud at boot when the global pipe cannot see a
|
||||
* guarded DTO's decorated properties — the #436 class-erasure signature and
|
||||
* its dependency-graph cousins. Red/green arms:
|
||||
*
|
||||
* GREEN real module state: BootstrapSetupDto's three properties are
|
||||
* decorated and visible through the globalThis-shared storage.
|
||||
* RED a control class with NO decorators (the erasure shape): the
|
||||
* check throws PipeMetatypeCheckError naming every property.
|
||||
* RED-2 a control where one property is decorated and two are not: the
|
||||
* error names exactly the missing two — the miss list is precise,
|
||||
* not a blanket failure.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { IsString } from 'class-validator';
|
||||
import {
|
||||
assertValidationPipeSeesDtoDecorators,
|
||||
PipeMetatypeCheckError,
|
||||
} from './validation-pipe-check.js';
|
||||
|
||||
describe('assertValidationPipeSeesDtoDecorators (#1391 boot check)', () => {
|
||||
it('GREEN: passes on real module state (decorated DTO visible to the pipe)', () => {
|
||||
expect(() => assertValidationPipeSeesDtoDecorators()).not.toThrow();
|
||||
});
|
||||
|
||||
it('RED control: a class whose properties lost their decorators throws, naming them', async () => {
|
||||
// Simulate metatype erasure: an undecorated class standing where a
|
||||
// decorated DTO should be. Redefine the guard table for the test by
|
||||
// importing the module and pointing its table at the eroded class —
|
||||
// the check reads the table at call time, so a fresh module instance
|
||||
// with a swapped table reproduces the boot failure deterministically.
|
||||
const { PIPE_GUARDED_DTOS } = await import('./validation-pipe-check.js');
|
||||
|
||||
class ErodedDto {
|
||||
name?: string;
|
||||
email?: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
const original = PIPE_GUARDED_DTOS[0];
|
||||
expect(original).toBeDefined();
|
||||
// Swap in the eroded target (same declared properties, zero decorators).
|
||||
(
|
||||
PIPE_GUARDED_DTOS as unknown as Array<{ name: string; target: object; properties: string[] }>
|
||||
).splice(0, PIPE_GUARDED_DTOS.length, {
|
||||
name: 'ErodedDto',
|
||||
target: ErodedDto,
|
||||
properties: ['name', 'email', 'password'],
|
||||
});
|
||||
|
||||
try {
|
||||
expect(() => assertValidationPipeSeesDtoDecorators()).toThrow(PipeMetatypeCheckError);
|
||||
try {
|
||||
assertValidationPipeSeesDtoDecorators();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : '';
|
||||
expect(message).toContain('ErodedDto.name');
|
||||
expect(message).toContain('ErodedDto.email');
|
||||
expect(message).toContain('ErodedDto.password');
|
||||
}
|
||||
} finally {
|
||||
// Restore real module state for any later test in this file.
|
||||
(PIPE_GUARDED_DTOS as unknown as unknown[]).splice(0, PIPE_GUARDED_DTOS.length, original);
|
||||
}
|
||||
// And confirm the restore is real.
|
||||
expect(() => assertValidationPipeSeesDtoDecorators()).not.toThrow();
|
||||
});
|
||||
|
||||
it('RED-2 control: a partially decorated class names exactly the missing properties', async () => {
|
||||
const { PIPE_GUARDED_DTOS } = await import('./validation-pipe-check.js');
|
||||
|
||||
class HalfErodedDto {
|
||||
@IsString()
|
||||
name?: string;
|
||||
email?: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
const original = PIPE_GUARDED_DTOS[0];
|
||||
(
|
||||
PIPE_GUARDED_DTOS as unknown as Array<{ name: string; target: object; properties: string[] }>
|
||||
).splice(0, PIPE_GUARDED_DTOS.length, {
|
||||
name: 'HalfErodedDto',
|
||||
target: HalfErodedDto,
|
||||
properties: ['name', 'email', 'password'],
|
||||
});
|
||||
|
||||
try {
|
||||
try {
|
||||
assertValidationPipeSeesDtoDecorators();
|
||||
expect.unreachable('partially decorated DTO must fail the boot check');
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : '';
|
||||
expect(message).toContain('HalfErodedDto.email');
|
||||
expect(message).toContain('HalfErodedDto.password');
|
||||
expect(message).not.toContain('HalfErodedDto.name has no');
|
||||
}
|
||||
} finally {
|
||||
(PIPE_GUARDED_DTOS as unknown as unknown[]).splice(0, PIPE_GUARDED_DTOS.length, original);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import 'reflect-metadata';
|
||||
import { getMetadataStorage } from 'class-validator';
|
||||
import { BootstrapSetupDto } from './admin/bootstrap.dto.js';
|
||||
|
||||
/**
|
||||
* Boot-time self-check: the global ValidationPipe must be able to SEE the
|
||||
* decorated properties of the DTOs it guards (#1391, #436 class).
|
||||
*
|
||||
* WHY THIS EXISTS. When Nest resolves a @Body() metatype to Object — via
|
||||
* `import type` class erasure (#436), or a dependency graph where the
|
||||
* controller's decorators and the application's route enhancers disagree
|
||||
* (#1391's hypothesized dual-@nestjs/common on a mixed install) — the
|
||||
* ValidationPipe's whitelist treats every property as forbidden. The first
|
||||
* symptom is a 400 on the FIRST bootstrap attempt of a fresh install, the
|
||||
* worst place to discover wiring damage: the operator cannot tell a broken
|
||||
* payload from a broken daemon.
|
||||
*
|
||||
* This check fails LOUD at boot instead: if the pipe cannot see the DTO's
|
||||
* decorated properties, the gateway refuses to start with a named cause.
|
||||
* It catches the whole class — erasure, decorator metadata loss — on every
|
||||
* host, at the moment the damage exists rather than at first use.
|
||||
*
|
||||
* Storage sharing note: class-validator keys its metadata storage on
|
||||
* globalThis, so duplicate package copies do NOT hide metadata (measured,
|
||||
* #1391 diagnosis). What hides it is losing the metatype itself, which is
|
||||
* what this asserts against.
|
||||
*/
|
||||
|
||||
/**
|
||||
* DTOs the global pipe guards, mapped to the properties the whitelist must
|
||||
* admit. Target is the CONSTRUCTOR (the object class itself): class-validator
|
||||
* decorators register metadata keyed on the constructor, and its executor
|
||||
* looks up `object.constructor` (ValidationExecutor.js:50) — the probe
|
||||
* through `prototype` returns zero. Extend when adding DTOs to the app.
|
||||
*/
|
||||
export const PIPE_GUARDED_DTOS: Array<{
|
||||
name: string;
|
||||
target: abstract new (...args: never[]) => unknown;
|
||||
properties: string[];
|
||||
}> = [
|
||||
{
|
||||
name: 'BootstrapSetupDto',
|
||||
target: BootstrapSetupDto,
|
||||
properties: ['name', 'email', 'password'],
|
||||
},
|
||||
];
|
||||
|
||||
export class PipeMetatypeCheckError extends Error {
|
||||
constructor(missing: string[]) {
|
||||
super(
|
||||
'ValidationPipe metatype check failed: ' +
|
||||
missing.join('; ') +
|
||||
'. The global ValidationPipe cannot see decorated DTO properties — ' +
|
||||
'every request body would be rejected as non-whitelisted. ' +
|
||||
'Check for import-type erasure or decorator metadata loss in the ' +
|
||||
'dependency graph (see issues #436, #1391).',
|
||||
);
|
||||
this.name = 'PipeMetatypeCheckError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert the pipe's whitelist can see every guarded DTO's decorated
|
||||
* properties. Throws PipeMetatypeCheckError (fail-loud at boot) listing
|
||||
* each miss. Pure function of module state: no I/O, safe to call twice.
|
||||
*/
|
||||
export function assertValidationPipeSeesDtoDecorators(): void {
|
||||
const storage = getMetadataStorage();
|
||||
const missing: string[] = [];
|
||||
|
||||
for (const dto of PIPE_GUARDED_DTOS) {
|
||||
// class-validator records constraints keyed on the DTO's constructor
|
||||
// (decorators run on the class), and its executor resolves them via
|
||||
// object.constructor. A property with no recorded metadata is invisible
|
||||
// to the whitelist — whatever the cause — and fails here.
|
||||
// Signature mirrors ValidationExecutor.js:50 — (constructor, schema, always,
|
||||
// strictGroups, groups?). No schema, always=true, no groups: every
|
||||
// constraint regardless of grouping, which is what the whitelist sees.
|
||||
const metadatas = storage.getTargetValidationMetadatas(dto.target, '', true, false);
|
||||
const decorated = new Set(metadatas.map((m) => m.propertyName));
|
||||
|
||||
for (const property of dto.properties) {
|
||||
if (!decorated.has(property)) {
|
||||
missing.push(
|
||||
`${dto.name}.${property} has no class-validator constraints visible to the pipe`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (missing.length > 0) {
|
||||
throw new PipeMetatypeCheckError(missing);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import 'reflect-metadata';
|
||||
import { type CanActivate, type ExecutionContext, type INestApplication } 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 { TeamsController } from './teams.controller.js';
|
||||
import { TeamsService } from './teams.service.js';
|
||||
|
||||
const teamAlpha = { id: 'team-alpha', name: 'Alpha' };
|
||||
const teamBeta = { id: 'team-beta', name: 'Beta' };
|
||||
|
||||
// user-1 is a member of team-alpha only; admin-1 has role admin.
|
||||
let currentUser: { id: string; role?: string } = { id: 'user-1' };
|
||||
|
||||
const teamsServiceMock = {
|
||||
findAll: vi.fn(() => Promise.resolve([teamAlpha, teamBeta])),
|
||||
findAllForUser: vi.fn((userId: string) =>
|
||||
Promise.resolve(userId === 'user-1' ? [teamAlpha] : []),
|
||||
),
|
||||
findById: vi.fn((id: string) => Promise.resolve([teamAlpha, teamBeta].find((t) => t.id === id))),
|
||||
listMembers: vi.fn(() => Promise.resolve([{ teamId: 'team-alpha', userId: 'user-1' }])),
|
||||
isMember: vi.fn((teamId: string, userId: string) =>
|
||||
Promise.resolve(teamId === 'team-alpha' && userId === 'user-1'),
|
||||
),
|
||||
};
|
||||
|
||||
const authGuard: CanActivate = {
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const requestContext = context
|
||||
.switchToHttp()
|
||||
.getRequest<{ user?: { id: string; role?: string } }>();
|
||||
requestContext.user = currentUser;
|
||||
return true;
|
||||
},
|
||||
};
|
||||
|
||||
describe('teams endpoints are scoped to membership', () => {
|
||||
let app: INestApplication;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
controllers: [TeamsController],
|
||||
providers: [{ provide: TeamsService, useValue: teamsServiceMock }],
|
||||
})
|
||||
.overrideGuard(AuthGuard)
|
||||
.useValue(authGuard)
|
||||
.compile();
|
||||
|
||||
app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
|
||||
await app.init();
|
||||
await app.getHttpAdapter().getInstance().ready();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
currentUser = { id: 'user-1' };
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('GET /api/teams returns only the teams the user belongs to', async () => {
|
||||
const response = await request(app.getHttpServer()).get('/api/teams');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual([teamAlpha]);
|
||||
expect(teamsServiceMock.findAll).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('GET /api/teams returns every team for an admin', async () => {
|
||||
currentUser = { id: 'admin-1', role: 'admin' };
|
||||
const response = await request(app.getHttpServer()).get('/api/teams');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual([teamAlpha, teamBeta]);
|
||||
expect(teamsServiceMock.findAllForUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('GET /api/teams/:teamId returns 403 for a non-member', async () => {
|
||||
const response = await request(app.getHttpServer()).get('/api/teams/team-beta');
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
|
||||
it('GET /api/teams/:teamId returns 404 for a missing team', async () => {
|
||||
const response = await request(app.getHttpServer()).get('/api/teams/team-missing');
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
it('GET /api/teams/:teamId returns the team for a member', async () => {
|
||||
const response = await request(app.getHttpServer()).get('/api/teams/team-alpha');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(teamAlpha);
|
||||
});
|
||||
|
||||
it('GET /api/teams/:teamId/members returns 403 for a non-member and members for a member', async () => {
|
||||
const denied = await request(app.getHttpServer()).get('/api/teams/team-beta/members');
|
||||
expect(denied.status).toBe(403);
|
||||
expect(teamsServiceMock.listMembers).not.toHaveBeenCalled();
|
||||
|
||||
const allowed = await request(app.getHttpServer()).get('/api/teams/team-alpha/members');
|
||||
expect(allowed.status).toBe(200);
|
||||
expect(allowed.body).toEqual([{ teamId: 'team-alpha', userId: 'user-1' }]);
|
||||
});
|
||||
|
||||
it('GET /api/teams/:teamId/members/:userId allows a self-lookup on any team', async () => {
|
||||
const response = await request(app.getHttpServer()).get('/api/teams/team-beta/members/user-1');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({ isMember: false });
|
||||
});
|
||||
|
||||
it('GET /api/teams/:teamId/members/:userId denies looking up another user on a foreign team', async () => {
|
||||
const response = await request(app.getHttpServer()).get('/api/teams/team-beta/members/user-2');
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
|
||||
it('an admin can look up any membership', async () => {
|
||||
currentUser = { id: 'admin-1', role: 'admin' };
|
||||
const response = await request(app.getHttpServer()).get('/api/teams/team-alpha/members/user-1');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({ isMember: true });
|
||||
});
|
||||
});
|
||||
@@ -1,30 +1,68 @@
|
||||
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
|
||||
import {
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
NotFoundException,
|
||||
Param,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard } from '../auth/auth.guard.js';
|
||||
import { CurrentUser } from '../auth/current-user.decorator.js';
|
||||
import { TeamsService } from './teams.service.js';
|
||||
|
||||
type RequestUser = { id: string; role?: string };
|
||||
|
||||
@Controller('api/teams')
|
||||
@UseGuards(AuthGuard)
|
||||
export class TeamsController {
|
||||
constructor(private readonly teams: TeamsService) {}
|
||||
|
||||
@Get()
|
||||
async list() {
|
||||
return this.teams.findAll();
|
||||
async list(@CurrentUser() user: RequestUser) {
|
||||
if (user.role === 'admin') {
|
||||
return this.teams.findAll();
|
||||
}
|
||||
return this.teams.findAllForUser(user.id);
|
||||
}
|
||||
|
||||
@Get(':teamId')
|
||||
async findOne(@Param('teamId') teamId: string) {
|
||||
return this.teams.findById(teamId);
|
||||
async findOne(@Param('teamId') teamId: string, @CurrentUser() user: RequestUser) {
|
||||
return this.getAccessibleTeam(teamId, user);
|
||||
}
|
||||
|
||||
@Get(':teamId/members')
|
||||
async listMembers(@Param('teamId') teamId: string) {
|
||||
async listMembers(@Param('teamId') teamId: string, @CurrentUser() user: RequestUser) {
|
||||
await this.getAccessibleTeam(teamId, user);
|
||||
return this.teams.listMembers(teamId);
|
||||
}
|
||||
|
||||
@Get(':teamId/members/:userId')
|
||||
async checkMembership(@Param('teamId') teamId: string, @Param('userId') userId: string) {
|
||||
async checkMembership(
|
||||
@Param('teamId') teamId: string,
|
||||
@Param('userId') userId: string,
|
||||
@CurrentUser() user: RequestUser,
|
||||
) {
|
||||
// A user may always ask about their own membership; anything else is
|
||||
// team-scoped like the other routes.
|
||||
if (userId !== user.id) {
|
||||
await this.getAccessibleTeam(teamId, user);
|
||||
}
|
||||
const isMember = await this.teams.isMember(teamId, userId);
|
||||
return { isMember };
|
||||
}
|
||||
|
||||
/**
|
||||
* Team-scoped access: admins see any team; everyone else only teams they
|
||||
* are a member of. NotFoundException when the team does not exist and
|
||||
* ForbiddenException when the user lacks access (same convention as the
|
||||
* projects controller).
|
||||
*/
|
||||
private async getAccessibleTeam(teamId: string, user: RequestUser) {
|
||||
const team = await this.teams.findById(teamId);
|
||||
if (!team) throw new NotFoundException('Team not found');
|
||||
if (user.role === 'admin') return team;
|
||||
const isMember = await this.teams.isMember(teamId, user.id);
|
||||
if (!isMember) throw new ForbiddenException('Not a member of this team');
|
||||
return team;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { eq, and, type Db, teams, teamMembers, projects } from '@mosaicstack/db';
|
||||
import { eq, and, inArray, type Db, teams, teamMembers, projects } from '@mosaicstack/db';
|
||||
import { DB } from '../database/database.module.js';
|
||||
|
||||
@Injectable()
|
||||
@@ -56,6 +56,21 @@ export class TeamsService {
|
||||
return this.db.select().from(teams);
|
||||
}
|
||||
|
||||
/**
|
||||
* List only the teams the user is a member of.
|
||||
*/
|
||||
async findAllForUser(userId: string) {
|
||||
const memberRows = await this.db
|
||||
.select({ teamId: teamMembers.teamId })
|
||||
.from(teamMembers)
|
||||
.where(eq(teamMembers.userId, userId));
|
||||
|
||||
const teamIds = memberRows.map((r) => r.teamId);
|
||||
if (teamIds.length === 0) return [];
|
||||
|
||||
return this.db.select().from(teams).where(inArray(teams.id, teamIds));
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a team by ID.
|
||||
*/
|
||||
|
||||
+242
-1014
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,77 @@
|
||||
---
|
||||
kind: spec
|
||||
status: active
|
||||
---
|
||||
|
||||
# Mosaic Stack Roadmap
|
||||
|
||||
Companion to [docs/PRD.md](./PRD.md). Governed by the D11 rule: **every planned
|
||||
phase appears here from day one, even as a placeholder** — nothing exists only
|
||||
in heads. A phase marked _placeholder_ is a commitment to design it, not a
|
||||
design; scoping one requires its own PRD section or requirements doc plus
|
||||
review.
|
||||
|
||||
Phases are product phases. The in-flight platform workstreams (KBN-100/101
|
||||
kanban SOT implementation, FCM #758, FCOM #766, TESS, RI #1275, and the other
|
||||
Part II contracts in the PRD) run as parallel tracks under their own issues
|
||||
and are prerequisites where noted.
|
||||
|
||||
| Phase | Scope | Status |
|
||||
| ----- | ------------------------------------------------------------------------------ | ----------------------------------------- |
|
||||
| P0 | Current state on `next`: read-only dashboard, chat, auth/SSO login, admin tabs | shipped, evolving |
|
||||
| P1 | **v1 slice** (PRD Part I §9) | next up |
|
||||
| P2 | Connectors + comms + wizard expansion | placeholder |
|
||||
| P3 | Full onboarding profile + M365 | placeholder |
|
||||
| P4 | Enterprise mode + one-way conversion | placeholder |
|
||||
| P5 | Federation | placeholder (deliberately undesigned, D3) |
|
||||
|
||||
## P0 — current state
|
||||
|
||||
What exists on `next` today: web dashboard (login/register/SSO, chat,
|
||||
read-only projects/tasks, settings, admin user/system-health tabs), the
|
||||
Gateway, the CLI-first framework tooling, and the fleet control plane. The
|
||||
webUI audit (USC estate, webui-audit lane) measures the gap between this and
|
||||
P1.
|
||||
|
||||
## P1 — v1 slice (D11)
|
||||
|
||||
1. Standalone onboarding wizard: system/company name, component choices,
|
||||
initial user, initial estate + project, seeded examples, re-runnable.
|
||||
2. Hierarchy core: company → estate → project → workspace → kanban, read-only
|
||||
task bubble-up (kanban SOT Amendment A1 is the schema contract).
|
||||
3. Basic RBAC on the hierarchy.
|
||||
4. Minimal agent enrollment: one harness, API key, name/persona.
|
||||
|
||||
Prerequisites: KBN-100/101 schema foundation; the D8 tool inventory and
|
||||
webUI→tool mapping (any missing tool is built first, D12).
|
||||
|
||||
## P2 — connectors + comms + wizard expansion (placeholder)
|
||||
|
||||
Email and drive connectors (Gmail/IMAP, Google Drive/OneDrive/Dropbox) with
|
||||
granular agentic-access consent; comms integrations (Matrix/Discord/Slack)
|
||||
including agent auto-enroll. Wizard gains the corresponding tabs (D4), plus
|
||||
the D4 capabilities deferred out of P1's minimal slice: expanded agent
|
||||
enrollment (OAuth login, multi-account, model choice with recommendation,
|
||||
account assignment, comms auto-enroll) and the Standalone SSO/OIDC
|
||||
configuration tab.
|
||||
|
||||
## P3 — full onboarding profile + M365 (placeholder)
|
||||
|
||||
Complete user onboarding profile (communication-style capture, optional
|
||||
voice-matching interview) under the D14 custody rule; M365 connectors,
|
||||
available to both deployment modes as ordinary connectors (same consent model
|
||||
as the P2 connector class). The Enterprise install flow's M365 prominence
|
||||
(D4) arrives with the Enterprise phase, P4.
|
||||
|
||||
## P4 — Enterprise mode + conversion (placeholder)
|
||||
|
||||
Enterprise install flow (org chart, RBAC focus, immediate OIDC, SSO
|
||||
prominent); per-user brains with architectural isolation (D14); Vault
|
||||
required; the one-way Standalone → Enterprise conversion (D3).
|
||||
|
||||
## P5 — federation (placeholder)
|
||||
|
||||
Connecting deployments: system-level config, assigned users, rights and
|
||||
data-access control, trusts with boundaries, strict data access, exfiltration
|
||||
monitoring. Explicitly not designed yet (D3); nothing in earlier phases may
|
||||
foreclose it. Requires its own PRD + threat model before any scoping.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -372,3 +372,87 @@ The P0–P3 canon does not authorize:
|
||||
## 7. Global release evidence
|
||||
|
||||
P0–P3 may close only when requirements traceability maps every requirement above to automated and situational evidence, including cross-workspace denials, DB/Valkey fault injection, concurrent leases, stale fencing, generated-file immutability, UI conflict/reconnect behavior, migration reconciliation, independent review, mandatory SecReview, and final Certifier evidence.
|
||||
|
||||
## 8. Amendment A1 — hierarchy parentage and RBAC chain above workspaces
|
||||
|
||||
**Status:** amendment to the ratified canon, added by reviewed PR under
|
||||
decision D13 (operator ruling, 2026-08-25; decision owner Jason). It adds
|
||||
parent structure ABOVE workspaces. Sections 1–7, every invariant in §3, and
|
||||
every REQ above remain binding verbatim, with exactly one express modification:
|
||||
the narrow portfolio-analytics carve-out stated in §8.2.4. Nothing else below
|
||||
this line is weakened.
|
||||
|
||||
### 8.1 What is added
|
||||
|
||||
1. A platform hierarchy exists above workspaces:
|
||||
**company/organization → estate → platform-project → workspace**. Each
|
||||
workspace belongs to exactly one platform-project, each platform-project to
|
||||
exactly one estate, each estate to exactly one company.
|
||||
2. **Record class.** Hierarchy records (company, estate, platform-project,
|
||||
their parentage edges, and hierarchy-level access grants) are a new,
|
||||
explicitly named record class: **tenancy/authorization structure records**.
|
||||
They are not business or orchestration records, so §3 invariant 10 and
|
||||
REQ-TEN-001 do not apply to them and are not weakened by them — those two
|
||||
requirements bind business/orchestration rows exactly as before.
|
||||
Constraints on the new class:
|
||||
- Hierarchy tables MUST NOT carry task, plan, or any other
|
||||
business/orchestration payload — parentage, naming, and grant data only.
|
||||
- A hierarchy record can never be the subject of work: it cannot be
|
||||
claimed, ordered, gated, or referenced as a dependency by any
|
||||
business/orchestration row.
|
||||
- Hierarchy mutations flow through the same sole-writable-SOT, fail-closed,
|
||||
audited mutation path as everything else (§8.2.3).
|
||||
3. The hierarchy serves exactly two runtime functions, plus audited
|
||||
maintenance of its own structure:
|
||||
- **RBAC evaluation:** access grants are declared per company, estate, or
|
||||
platform-project and evaluate down the chain to workspace-scoped
|
||||
authorization. Tenant context continues to be derived from authenticated
|
||||
authority (REQ-TEN-001); the chain adds where grants can be declared,
|
||||
not a bypass of workspace authorization.
|
||||
- **Read-only roll-ups:** task and status visualization bubbles up the
|
||||
hierarchy as aggregation over workspaces the reader is authorized on.
|
||||
- **Chain maintenance (not a third runtime function):** re-parenting an
|
||||
asset — moving a workspace to another platform-project, a
|
||||
platform-project to another estate, and so on ("assets are transferable
|
||||
subject to the structure", PRD Part I §4) — is an audited edit of the
|
||||
hierarchy records themselves under §8.3. It never modifies
|
||||
business/orchestration rows and never crosses a workspace boundary for
|
||||
them; the workspace's contents move with the workspace untouched.
|
||||
4. Naming: this amendment says **platform-project** for the hierarchy level
|
||||
above workspaces, because §5 REQ-PLAN-001 already defines `projects` as
|
||||
planning entities INSIDE a workspace. The two are different objects. Final
|
||||
terminology (rename of one or the other) is an implementation-PR decision
|
||||
under this amendment's review; the schema MUST NOT merge them.
|
||||
|
||||
### 8.2 What is explicitly unchanged
|
||||
|
||||
1. `workspace_id` remains the hard mechanical isolation unit (§2 D2,
|
||||
REQ-TEN-001). Hierarchy tables carry parentage; they do not create
|
||||
cross-workspace relationships between business/orchestration rows, which
|
||||
remain rejected (§3 invariant 10).
|
||||
2. Roll-up is **never a write**: no aggregation path may mutate, claim, order,
|
||||
or gate work in any workspace. Bubble-up views are generated projections in
|
||||
the sense of §3 invariant 5 — non-authoritative and never import sources.
|
||||
3. Fail-closed mutation health (§3 invariants 3–4), sole writable PostgreSQL
|
||||
SOT, fencing, audit, and the Coordinator/Certifier authority rules are
|
||||
untouched.
|
||||
4. No §6 non-goal is authorized, with one express, narrow carve-out that this
|
||||
amendment makes to the "portfolio analytics" non-goal: the read-only
|
||||
roll-up of §8.1 — per-workspace task counts and statuses aggregated up the
|
||||
parent chain, over workspaces the reader is authorized on — is in scope.
|
||||
Everything beyond that boundary (metrics, trends, forecasting, scoring,
|
||||
dashboards computed across workspaces, any derived analytic that is not a
|
||||
direct count/status aggregation) remains a non-goal. This is an explicit
|
||||
narrowing by amendment, not a claim that §6 is unchanged; every other §6
|
||||
non-goal is untouched.
|
||||
|
||||
### 8.3 Acceptance (binding on the implementing PRs)
|
||||
|
||||
- Schema tests prove each workspace resolves to exactly one
|
||||
platform-project/estate/company chain and that chain edits are audited.
|
||||
- Authorization tests prove a grant at each hierarchy level yields exactly the
|
||||
workspace permissions the chain implies, and that revocation up the chain
|
||||
propagates.
|
||||
- Negative tests prove roll-up endpoints cannot mutate state and that a
|
||||
reader sees aggregates only over workspaces they are authorized on
|
||||
(no cross-tenant existence oracles).
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
# RBAC Grant Model Contract
|
||||
|
||||
Status: DRAFT — awaiting ratification (webui-audit S2, contract 2 of 9).
|
||||
Authority: PRD Part I §4 ("Granular RBAC: admins restrict access per company,
|
||||
estate, and project; grants are evaluated down the chain") and the
|
||||
native-kanban SOT Amendment A1 (§8.1.3 RBAC evaluation, §8.3 acceptance 2).
|
||||
This document defines the grant vocabulary, evaluation semantics, and
|
||||
revocation propagation that the hierarchy schema contract
|
||||
(`docs/requirements/hierarchy-schema.md`, contract 1) attaches to. Contract 1
|
||||
pins the `hierarchy_grants` table shape and defers the `role` vocabulary and
|
||||
the meaning of "authority" here; the identity contract
|
||||
(`docs/requirements/identity-lifecycle.md` §1.4) pins that account creation
|
||||
grants nothing.
|
||||
|
||||
Revision 2 (independent review, GLM 5.3): §1.1 consequence analysis
|
||||
completed — the two existing platform-admin bypass code paths are named as
|
||||
non-conformant and §7.4 retires them; team grant subjects suspended pending
|
||||
a team contract (§1.4, §3.3–3.4, §7.5); no-self-escalation restated with
|
||||
its true rationale and a constructible observable (§4.2, §7.7);
|
||||
node-creation seeding scoped to the bootstrap path, resolving the §7.7/§4.3
|
||||
contradiction; A1 quotation corrected; audit-field provenance corrected;
|
||||
principal-position consequence named (§1.3); membership-row,
|
||||
fail-closed-fault, and existence-oracle observables added (§7);
|
||||
role-string namespacing rule added (§4.5); ruling request now names the
|
||||
interpretive resolution of PRD "admins".
|
||||
|
||||
Scope: the roles that can appear in `hierarchy_grants.role`, what a grant at
|
||||
each hierarchy level confers, how grants evaluate down the chain, how
|
||||
revocation propagates, and who may manage grants. Out of scope: the hierarchy
|
||||
tables themselves (contract 1), workspace-internal membership and its
|
||||
role/capability vocabulary (native-kanban SOT REQ-ID-001 and its implementing
|
||||
schema), roll-up projection semantics (contract 8), wizard seeding
|
||||
(contract 3), the team model (suspended here; see §1.4).
|
||||
|
||||
## 1. Three authority layers, none substitutable
|
||||
|
||||
1. **Platform role** (`users.role`, better-auth: `member` | `admin`) governs
|
||||
instance administration — user management, system settings, provider
|
||||
configuration. It is not tenancy authority: holding platform `admin`
|
||||
confers **no implicit hierarchy grant and no workspace authorization**.
|
||||
An operator who should see tenant content holds an explicit, audited
|
||||
grant like anyone else. This is the deny-by-default consequence of A1
|
||||
§8.1.3 ("not a bypass of workspace authorization"). `AdminGuard`'s
|
||||
`role === 'admin'` check on admin endpoints stays the platform role's
|
||||
only meaning. **Two shipped code paths violate this rule today and are
|
||||
implementation defects this contract makes non-conformant:** (a) the
|
||||
command authorization service short-circuits every command scope to
|
||||
allowed for platform admins
|
||||
(`apps/gateway/src/commands/command-authorization.service.ts`,
|
||||
`hasScope` returning true when `role === 'admin'`), and (b) the MCP
|
||||
scope derivation maps platform `admin` to tenant-admin MCP scopes
|
||||
including task create/update
|
||||
(`apps/gateway/src/mcp/mcp.service.ts`,
|
||||
`deriveMcpToolScopesForUser`). Ratifying this contract revokes both;
|
||||
§7.4 names them as the surfaces the deny-by-default test retires.
|
||||
2. **Hierarchy grants** (`hierarchy_grants`, contract 1 §3) declare tenancy
|
||||
authority at company, estate, or platform-project scope and evaluate down
|
||||
the chain to workspace-scoped authorization (§3 below).
|
||||
3. **Workspace membership** (SOT REQ-ID-001) remains its own mechanism.
|
||||
A chain grant confers command authorization over descendant workspaces;
|
||||
it does not create membership rows, and row-level principal positions
|
||||
(task owner, proposer, decision actor) still require ACTIVE workspace
|
||||
membership exactly as REQ-TEN-001/REQ-ID-001 acceptance states.
|
||||
Consequence, stated so implementing PRs do not weaken REQ-TEN-001 to
|
||||
remove the friction: a chain-granted actor who is not a workspace member
|
||||
may issue the write commands their role implies but cannot occupy a
|
||||
principal position — any command taking a principal argument must name
|
||||
an ACTIVE member of the target workspace (§7.2 enumerates this cell).
|
||||
4. **Team grant subjects are suspended.** Contract 1 §3.1 reserves a
|
||||
`team_id` attachment point, but no ratified contract yet defines the
|
||||
team it would bind: the only existing `teams` table is the legacy global
|
||||
Brain table (own authority columns, no workspace binding, not
|
||||
repurposed per contract 1 §1.3), while the SOT's teams are
|
||||
workspace-bound (REQ-ID-001) — and a workspace-bound team holding a
|
||||
company-level grant would be a cross-workspace authority group nothing
|
||||
has ratified. Until a team contract defines the subject (which table,
|
||||
which membership rows, and its relation to D2/REQ-ID-001), creating a
|
||||
grant with a team subject MUST be refused at the command surface (the
|
||||
schema column remains, per contract 1). §3's evaluation semantics for
|
||||
team-conferred grants are specified now so the team contract activates
|
||||
them without amending this one.
|
||||
|
||||
## 2. Role vocabulary
|
||||
|
||||
One vocabulary at every hierarchy level, totally ordered — a higher role
|
||||
includes everything below it:
|
||||
|
||||
1. `viewer` — read: sees the node, its subtree structure, and the roll-up
|
||||
aggregates over descendant workspaces (within contract 8's carve-out
|
||||
bounds); read access to descendant workspace content per the SOT's read
|
||||
command families. No mutation of anything.
|
||||
2. `member` — work: everything `viewer` has, plus write authorization for
|
||||
business/orchestration command families in descendant workspaces (the
|
||||
concrete command-family mapping is implementation work under SOT
|
||||
REQ-ID-001; this contract pins that `member` maps to the workspace write
|
||||
families and nothing structural).
|
||||
3. `owner` — structure: everything `member` has, plus hierarchy mutations on
|
||||
the subtree (create/rename/delete child nodes, transfers per §5), and
|
||||
grant management on the node and its subtree (§4).
|
||||
|
||||
No other value is valid in `hierarchy_grants.role`; the column is
|
||||
constraint-checked against exactly these three. Extending the vocabulary is a
|
||||
contract amendment, not an implementation decision.
|
||||
|
||||
## 3. Evaluation semantics
|
||||
|
||||
1. **Deny by default.** No grant on any ancestor → no authority. There are
|
||||
no implicit grants: not from platform role (§1.1), not from creating a
|
||||
node (§4.3), not from workspace membership (membership without a chain
|
||||
grant confers exactly what the SOT's own membership rules confer inside
|
||||
that workspace, nothing up the chain).
|
||||
2. **Down-the-chain only.** A grant on a node applies to that node and its
|
||||
entire descendant subtree. Nothing evaluates upward or sideways: a grant
|
||||
on an estate says nothing about the parent company or sibling estates.
|
||||
3. **Effective role = maximum.** A subject's effective role at any node is
|
||||
the highest role among grants held directly by the subject's user on
|
||||
that node or any ancestor — and, once the team contract activates team
|
||||
subjects (§1.4), grants held by any team the user is a member of on that
|
||||
node or any ancestor. Roles never subtract — there is no negative/deny
|
||||
grant in this model; revocation is deletion (§6).
|
||||
4. **Team grants follow live membership** (specified now, active only per
|
||||
§1.4). A team grant confers its role on the team's current members,
|
||||
evaluated at decision time. Leaving the team is loss of the grant with
|
||||
§6's propagation bound.
|
||||
5. **Live evaluation, fail closed.** Authorization decisions derive from the
|
||||
live grant and team-membership rows (or from a cache that is invalidated
|
||||
in the same transaction as any grant/membership/hierarchy mutation). A
|
||||
decision path that cannot read grant state denies. No materialized ACL is
|
||||
ever authoritative.
|
||||
6. **Tenant context stays derived from authenticated authority**
|
||||
(REQ-TEN-001). The chain adds where grants can be declared; a workspace
|
||||
request is still authorized against that workspace, with the chain
|
||||
contributing the effective role — never letting the chain become what A1
|
||||
§8.1.3 forbids: "a bypass of workspace authorization".
|
||||
|
||||
## 4. Grant management
|
||||
|
||||
1. Creating, changing, or revoking a grant on a node requires effective
|
||||
`owner` on that node (directly or via any ancestor).
|
||||
2. **No self-escalation.** A grant manager cannot create a grant with a role
|
||||
higher than their own effective role on the target node. Under the §2
|
||||
vocabulary this rule is currently implied by §4.1 (managers are `owner`,
|
||||
the top role — no constructible grant exceeds it); it is stated
|
||||
explicitly so it survives any future amendment that decouples
|
||||
grant-management authority from role height. Its observable is the §7.7
|
||||
audit invariant, not a refusal test.
|
||||
3. **Bootstrap of authority is explicit; inheritance covers the rest.**
|
||||
Creating the first company (the wizard path, contract 3) and any
|
||||
top-level company creation MUST name the initial `owner` grant in the
|
||||
same audited operation — a top-level node has no ancestor to inherit
|
||||
from, so without this the node would be unownable. Creating a child node
|
||||
(estate, platform-project, workspace) requires effective `owner` on the
|
||||
parent (§2.3) and confers no automatic grant; the creator's authority
|
||||
over the new node already follows from §3.2 down-the-chain evaluation.
|
||||
The creating command MAY additionally name an explicit initial grant for
|
||||
a child node; it is not required to.
|
||||
4. Every grant mutation is a semantic audit event under contract 1 §5.2's
|
||||
guarantees, extended by this contract with two further fields: the event
|
||||
carries actor, verb, target, **subject, and role** (subject and role are
|
||||
this contract's addition; contract 1 §5.2 does not enumerate them).
|
||||
5. **Role strings are namespaced.** `viewer`/`member` exist at hierarchy
|
||||
level, `member`/`admin` on `users.role`, and the current command layer
|
||||
uses a third `viewer|member|admin` vocabulary — same strings, different
|
||||
meanings. Any serialized role string (audit events per §4.4, API
|
||||
responses, logs) MUST identify its layer (e.g. `hierarchy:owner`,
|
||||
`platform:admin`); a bare role string in a serialized artifact is
|
||||
non-conformant.
|
||||
|
||||
## 5. Transfer authority (completes contract 1 §4.2)
|
||||
|
||||
"Authority over BOTH the source and the destination parent" means: effective
|
||||
`owner` on the current parent node (or an ancestor) AND effective `owner` on
|
||||
the destination parent node (or an ancestor), evaluated at transfer time in
|
||||
the transfer's own transaction. One subject must hold both; two cooperating
|
||||
half-authorized subjects are not a transfer protocol this contract defines.
|
||||
|
||||
## 6. Revocation propagation
|
||||
|
||||
1. Revoking a grant (deleting the row), removing a user from a team that
|
||||
carries a grant (once team subjects activate, §1.4), or the cascade
|
||||
deletion of a node's grants during node deletion (contract 1 §3.3) all
|
||||
propagate identically: the authority derived from that grant is gone for
|
||||
every descendant workspace.
|
||||
2. **Bound:** the next authorization decision on any affected transport
|
||||
decides against the revoked grant. Concretely: no new HTTP/MCP command
|
||||
authorized by the revoked grant after the revoking transaction commits;
|
||||
an open Socket.IO connection whose subscriptions depend on the revoked
|
||||
grant is re-evaluated within 30 seconds or at its next inbound message,
|
||||
whichever comes first (same bound as the identity contract's §7.1
|
||||
deactivation rule; same mechanism may serve both).
|
||||
3. Revocation is subtractive only in effect, not in representation: the
|
||||
evaluator never needs tombstones; deletion of the row is the revocation.
|
||||
|
||||
## 7. Verification requirements
|
||||
|
||||
Binding on the implementing PRs (extends A1 §8.3 acceptance 2–3 and
|
||||
contract 1 §6):
|
||||
|
||||
1. Vocabulary: the role CHECK constraint rejects any value outside
|
||||
`viewer|member|owner` (real-PostgreSQL witness, `ci-postgres` service in
|
||||
the `test` CI step).
|
||||
2. Per-level conferral: for each of the three levels × three roles, a grant
|
||||
yields exactly the implied workspace authorization in a descendant
|
||||
workspace and nothing in a non-descendant workspace (the A1 §8.3
|
||||
"exactly the permissions the chain implies" matrix, enumerated). The
|
||||
matrix includes: a chain grant creates zero workspace-membership rows
|
||||
(assert row counts); a chain-granted non-member is refused as the
|
||||
principal argument of any principal-taking command while their
|
||||
non-principal writes succeed (§1.3); structure reads leak no existence
|
||||
of nodes the reader holds no grant on (no cross-tenant existence
|
||||
oracle, A1 §8.3 acceptance 3).
|
||||
3. Ordering: `owner` ⊇ `member` ⊇ `viewer` behaviorally — each higher role
|
||||
passes every lower role's positive cases.
|
||||
4. Deny-by-default: platform `admin` with no grant reaches no tenant
|
||||
content — asserted against the two §1.1 non-conformant surfaces after
|
||||
their retirement: the command-authorization admin short-circuit and the
|
||||
MCP tenant-admin scope derivation both gone (a platform admin with no
|
||||
grant is refused workspace commands and receives no tenant MCP scopes);
|
||||
workspace member with no chain grant gains nothing outside SOT
|
||||
membership semantics; fresh account reaches nothing (identity contract
|
||||
§1.4 cross-check).
|
||||
5. Team subjects: while suspended (§1.4), creating a team-subject grant is
|
||||
refused at the command surface. On activation by the team contract:
|
||||
user-direct and team-conferred grants combine to the maximum; team-leave
|
||||
drops authority within the §6.2 bound; decision-time evaluation
|
||||
witnessed (grant added → next decision allows; no restart or re-login
|
||||
required).
|
||||
6. Revocation: each revocation path in §6.1 denies the next command on
|
||||
every transport; the socket bound is measured; a cached-authorization
|
||||
implementation proves transactional invalidation (grant revoked and
|
||||
decision made on two distinct physical connections). Fail-closed fault
|
||||
witness for §3.5: with grant state unreadable (fault injection), the
|
||||
decision denies.
|
||||
7. Grant management: non-`owner` cannot mutate grants; top-level company
|
||||
creation without the named initial `owner` grant is refused, while child
|
||||
node creation under ancestor authority succeeds without one (§4.3 both
|
||||
directions); every mutation produces its audit event with the §4.4
|
||||
fields. Self-escalation observable: over the audit event stream, every
|
||||
grant-create/change event's role is ≤ the acting user's effective role
|
||||
on the target at event time (reconstructable invariant, not a refusal
|
||||
test — see §4.2).
|
||||
8. Transfer: both-sides `owner` accepted, each single-side case refused
|
||||
(completing contract 1 §6.5).
|
||||
|
||||
## Ruling request
|
||||
|
||||
Ratify sections 1–7 as written, with one decision embedded and one
|
||||
interpretive resolution named:
|
||||
|
||||
- Decision: platform `admin` confers no implicit tenant access — operators
|
||||
see tenant content only through explicit, audited grants (§1.1), which
|
||||
retires the two existing admin bypass paths named there. Say "agreed" or
|
||||
name the implicit access you want platform admins to keep.
|
||||
- Interpretive resolution (for visibility, not a separate question): PRD
|
||||
Part I §4 says "admins restrict access per company, estate, and project";
|
||||
this contract resolves "admins" as hierarchy `owner`s (§4.1), not
|
||||
platform admins. A1 §8.1.3 does not attribute grant declaration to
|
||||
platform admins, and the §1.1 decision above is what makes this reading
|
||||
binding.
|
||||
@@ -0,0 +1,9 @@
|
||||
ALTER TABLE "accounts" ADD COLUMN "issuer" text;
|
||||
--> statement-breakpoint
|
||||
-- Backfill (#1395): better-auth >=1.7 sign-in filters accounts on
|
||||
-- (provider_id = 'credential' AND issuer = 'local:credential'). Existing
|
||||
-- credential rows predate the column and would fail that filter on upgraded
|
||||
-- installs. Credential rows ONLY: better-auth owns issuer semantics for
|
||||
-- oauth/sso rows going forward (each provider's real issuer value), so those
|
||||
-- stay NULL until the provider's next flow writes them.
|
||||
UPDATE "accounts" SET "issuer" = 'local:credential' WHERE "provider_id" = 'credential' AND "issuer" IS NULL;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -120,6 +120,13 @@
|
||||
"when": 1784050648841,
|
||||
"tag": "0016_salty_morlocks",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 17,
|
||||
"version": "7",
|
||||
"when": 1787609223282,
|
||||
"tag": "0017_accounts_issuer",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,75 @@ describe('runPgliteMigrations', () => {
|
||||
await expect(runPgliteMigrations(handle)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('gives accounts an issuer column (#1395) — better-auth >=1.7 requires it', async () => {
|
||||
await runPgliteMigrations(handle);
|
||||
|
||||
const result = (await handle.db.execute(sql`
|
||||
SELECT column_name, is_nullable, data_type
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'accounts' AND column_name = 'issuer'
|
||||
`)) as unknown as {
|
||||
rows: Array<{ column_name: string; is_nullable: string; data_type: string }>;
|
||||
};
|
||||
|
||||
// Nullable by design: the 1.5.x line this repo's lockfile resolves to does
|
||||
// not write the field; 1.7+ populates it. One schema serves both.
|
||||
expect(result.rows).toHaveLength(1);
|
||||
expect(result.rows[0]?.is_nullable).toBe('YES');
|
||||
expect(result.rows[0]?.data_type).toBe('text');
|
||||
});
|
||||
|
||||
it('backfills ONLY credential rows with the synthetic issuer (#1395 upgrade path)', async () => {
|
||||
// Simulate an upgraded install: migrate through 0016 only, seed pre-issuer
|
||||
// rows (one credential, one oauth), then apply 0017 and discriminate.
|
||||
const client = (handle.db as unknown as { $client: PgliteExec }).$client;
|
||||
|
||||
// Migrate to 0016 by replaying every ledger file except 0017 — the ledger
|
||||
// table gates re-application, so a plain replay of 0000..0016 is enough.
|
||||
const fs = await import('node:fs');
|
||||
const path = await import('node:path');
|
||||
const dir = path.join(import.meta.dirname, '..', 'drizzle');
|
||||
const files = fs
|
||||
.readdirSync(dir)
|
||||
.filter((f) => /^\d{4}_.*\.sql$/.test(f) && f < '0017')
|
||||
.sort();
|
||||
for (const f of files) {
|
||||
const raw = fs.readFileSync(path.join(dir, f), 'utf-8');
|
||||
for (const stmt of raw.split('--> statement-breakpoint')) {
|
||||
const trimmed = stmt.trim();
|
||||
if (trimmed) await client.exec(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
await client.exec(`
|
||||
INSERT INTO users (id, name, email, email_verified, created_at, updated_at)
|
||||
VALUES ('u1', 'Legacy User', '[email protected]', true, now(), now());
|
||||
INSERT INTO accounts (id, account_id, provider_id, user_id, created_at, updated_at)
|
||||
VALUES
|
||||
('a1', '[email protected]', 'credential', 'u1', now(), now()),
|
||||
('a2', 'oauth-provider-1', 'google', 'u1', now(), now());
|
||||
`);
|
||||
|
||||
// Apply 0017 (column + backfill).
|
||||
const sql0017 = fs.readFileSync(path.join(dir, '0017_accounts_issuer.sql'), 'utf-8');
|
||||
for (const stmt of sql0017.split('--> statement-breakpoint')) {
|
||||
const trimmed = stmt.trim();
|
||||
if (trimmed) await client.exec(trimmed);
|
||||
}
|
||||
|
||||
const rows = (await handle.db.execute(sql`
|
||||
SELECT provider_id, issuer FROM accounts ORDER BY id
|
||||
`)) as unknown as { rows: Array<{ provider_id: string; issuer: string | null }> };
|
||||
|
||||
const byProvider = new Map(rows.rows.map((r) => [r.provider_id, r.issuer]));
|
||||
// Credential rows get better-auth's synthetic local issuer — the value
|
||||
// sign-in filters on (better-auth dist createLocalAccountIssuer).
|
||||
expect(byProvider.get('credential')).toBe('local:credential');
|
||||
// OAuth rows are LEFT NULL: better-auth owns their issuer semantics going
|
||||
// forward (each provider's real issuer on its next flow).
|
||||
expect(byProvider.get('google')).toBeNull();
|
||||
});
|
||||
|
||||
it('surfaces statement-level error context on failure and leaves no ledger row', async () => {
|
||||
// Pre-create a `users` table that conflicts with migration 0000's CREATE TABLE,
|
||||
// forcing it to fail without IF NOT EXISTS.
|
||||
|
||||
@@ -63,6 +63,12 @@ export const accounts = pgTable(
|
||||
id: text('id').primaryKey(),
|
||||
accountId: text('account_id').notNull(),
|
||||
providerId: text('provider_id').notNull(),
|
||||
// better-auth >=1.7 requires an issuer on every account row: credential
|
||||
// sign-up writes the synthetic 'local:credential', OAuth rows carry the
|
||||
// provider's real issuer, and sign-in filters on (providerId, issuer).
|
||||
// Nullable because the 1.5.x line this repo's lockfile resolves to does
|
||||
// not know the field — 1.5 ignores it, 1.7 populates it (#1395).
|
||||
issuer: text('issuer'),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
|
||||
@@ -23,8 +23,14 @@ absent. Do not use raw `tmux send-keys` for fleet messaging.
|
||||
```bash
|
||||
tools/git/pr-create.sh ... tools/git/issue-create.sh ... tools/git/pr-merge.sh ...
|
||||
tools/git/ci-queue-wait.sh --purpose push|merge # REQUIRED before any push/merge
|
||||
tools/git/repo-decl.sh # shared .mosaic/repo.json consumption lib (sourced)
|
||||
```
|
||||
|
||||
**Reviewer grants** — `tools/git/grant-reviewer.sh -u <user> [-r <owner>/<repo>] [-t <team>]` adds a
|
||||
review seat to an org repo through an org team (Gitea only; code read + issues/pulls write, verified
|
||||
by read-back). Team approvals do not count as official under branch protection unless the team is
|
||||
whitelisted — see the tool header.
|
||||
|
||||
**GITEA_LOGIN gotcha** — the wrappers default to login `mosaicstack`; on a USC repo that fails with
|
||||
`gitea / Error: GetUserByName ... not found`. Pick the login from the repo's `origin` host first:
|
||||
|
||||
|
||||
@@ -49,6 +49,10 @@ supply it explicitly on any host where the provider CLI's default account is an
|
||||
| `milestone-list.sh` | List milestones |
|
||||
| `milestone-close.sh` | Close a milestone |
|
||||
|
||||
| Access grants | |
|
||||
| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `grant-reviewer.sh` | Grant a review seat on an org repo via an org team (Gitea only): code read + issues/pulls write, verified by read-back. Team approvals count as official only if branch protection whitelists the team — see the tool header |
|
||||
|
||||
| Gates and guards | |
|
||||
| ----------------------- | --------------------------------------------------------------------------------------------------------- |
|
||||
| `ci-queue-wait.sh` | CI queue guard — required before push/merge (see below) |
|
||||
|
||||
@@ -11,7 +11,17 @@ PartOf=mosaic-tmux-holder.service
|
||||
# launcher would fail the unit. A skipped unit is the honest state for "enabled
|
||||
# but not yet configured"; systemd re-evaluates the condition on every start, so
|
||||
# the seat comes up on the next start once the reconciler has written env.
|
||||
ConditionPathExists=%h/.config/mosaic/fleet/agents/%i.env.generated
|
||||
#
|
||||
# #1408: the reconciler writes projections into the BRAIN home when one is
|
||||
# active (~/.mosaic/fleet/agents, mirroring start-agent-session.sh's brain-home
|
||||
# resolution), and into MOSAIC_HOME on a legacy single-tree host. A single
|
||||
# config-home condition therefore skipped every seat on brain-home estates —
|
||||
# measured on two estates: 27 projections vs 0, and 5 vs 0, gate never fired.
|
||||
# Two TRIGGERING conditions (the `|` prefix ORs same-type conditions, which
|
||||
# otherwise AND): either shape arms the unit; the launcher still resolves the
|
||||
# authoritative copy itself.
|
||||
ConditionPathExists=|%h/.config/mosaic/fleet/agents/%i.env.generated
|
||||
ConditionPathExists=|%h/.mosaic/fleet/agents/%i.env.generated
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
|
||||
@@ -32,6 +32,17 @@ if grep -qF -- '/bin/bash -lc' "$HOLDER"; then
|
||||
fail "holder must not start tmux through a login shell"
|
||||
fi
|
||||
grep -qF 'Requires=mosaic-tmux-holder.service' "$AGENT" || fail "agent does not require holder"
|
||||
# #1408: the projection condition must arm on EITHER home shape. Both lines must
|
||||
# carry the `|` triggering prefix — same-type conditions without it AND together,
|
||||
# which can never be true (one file cannot exist at two paths), so a bare-spelling
|
||||
# regression would disable autostart everywhere while reading as "has a condition".
|
||||
grep -qF 'ConditionPathExists=|%h/.config/mosaic/fleet/agents/%i.env.generated' "$AGENT" || \
|
||||
fail "agent lacks triggering condition for the config home projection"
|
||||
grep -qF 'ConditionPathExists=|%h/.mosaic/fleet/agents/%i.env.generated' "$AGENT" || \
|
||||
fail "agent lacks triggering condition for the brain home projection (#1408)"
|
||||
if grep -qE '^ConditionPathExists=[^|]' "$AGENT"; then
|
||||
fail "agent has a non-triggering ConditionPathExists — same-type conditions AND, re-arming #1408"
|
||||
fi
|
||||
grep -qF 'start-agent-session.sh' "$AGENT" || fail "agent unit does not call start-agent-session.sh"
|
||||
if grep -qE '^Environment(File)?=' "$AGENT" "$INTERACTION"; then
|
||||
fail "agent units must not accept ambient or projection environment before strict parsing"
|
||||
|
||||
@@ -304,6 +304,17 @@ if [ "$MODE" = stop ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# #1408 hazard: a seat still living on the DEFAULT tmux socket is invisible to the
|
||||
# declared-socket guard below, and launching over it creates a same-name duplicate that
|
||||
# name-addressed comms delivery cannot tell apart. Refuse with a distinct code (76,
|
||||
# after 75 broker-absent) so a cutover wave script can branch on "seat still on legacy
|
||||
# socket" vs "already running" (0) vs "broker absent" (75). Stopping the legacy session
|
||||
# belongs to the cutover procedure, never to this launcher.
|
||||
if [ -n "$MOSAIC_TMUX_SOCKET" ] && tmux has-session -t "=${AGENT_NAME}" 2>/dev/null; then
|
||||
echo "[fleet] FAIL_LAUNCH seat-on-legacy-socket: session '${AGENT_NAME}' exists on the DEFAULT tmux socket; stop it before launching on '${MOSAIC_TMUX_SOCKET}'." >&2
|
||||
exit 76
|
||||
fi
|
||||
|
||||
if _tmux has-session -t "=${AGENT_NAME}:0.0" 2>/dev/null; then
|
||||
echo "Mosaic agent session already running: $AGENT_NAME on socket ${MOSAIC_TMUX_SOCKET:-(default)}"
|
||||
exit 0
|
||||
@@ -421,9 +432,22 @@ if [ "$MOSAIC_AGENT_RUNTIME" = claude ]; then
|
||||
echo "WARNING: could not pre-trust workdir for claude agent $AGENT_NAME" >&2
|
||||
fi
|
||||
|
||||
LAUNCH_COMMAND=(mosaic yolo "$MOSAIC_AGENT_RUNTIME")
|
||||
if [ -n "$MOSAIC_AGENT_MODEL" ]; then LAUNCH_COMMAND+=(--model "$MOSAIC_AGENT_MODEL"); fi
|
||||
if [ -n "$MOSAIC_AGENT_REASONING" ]; then LAUNCH_COMMAND+=(--thinking "$MOSAIC_AGENT_REASONING"); fi
|
||||
# #1408 hazard: prefer the seat's own launch.sh when the brain provides one. It is the
|
||||
# path that binds the auth profile (CLAUDE_SECURESTORAGE_CONFIG_DIR) and seeds the seat
|
||||
# config; `mosaic yolo` relocates CLAUDE_CONFIG_DIR to the seat dir (launch.ts
|
||||
# activeSeatDir/harnessEnv) but performs neither, so a yolo-launched seat points its
|
||||
# config at a directory holding no credentials. The env -i allowlist below still
|
||||
# applies: launch.sh reads its own launch.env.
|
||||
SEAT_LAUNCH="${BRAIN_HOME}/fleet/agents/${AGENT_NAME}/launch.sh"
|
||||
if [ -x "$SEAT_LAUNCH" ]; then
|
||||
LAUNCH_COMMAND=("$SEAT_LAUNCH")
|
||||
echo "[fleet] launch path: seat launch.sh ($SEAT_LAUNCH)"
|
||||
else
|
||||
LAUNCH_COMMAND=(mosaic yolo "$MOSAIC_AGENT_RUNTIME")
|
||||
if [ -n "$MOSAIC_AGENT_MODEL" ]; then LAUNCH_COMMAND+=(--model "$MOSAIC_AGENT_MODEL"); fi
|
||||
if [ -n "$MOSAIC_AGENT_REASONING" ]; then LAUNCH_COMMAND+=(--thinking "$MOSAIC_AGENT_REASONING"); fi
|
||||
echo "[fleet] launch path: mosaic yolo (no executable seat launch.sh)"
|
||||
fi
|
||||
|
||||
# The tmux holder owns a named server. Explicitly clear the pane environment
|
||||
# so server/session variables cannot cross the launch boundary; retain only
|
||||
|
||||
+216
@@ -0,0 +1,216 @@
|
||||
#!/usr/bin/env bash
|
||||
# CI-fit regression suite for the #1408 legacy-socket guard in
|
||||
# start-agent-session.sh.
|
||||
#
|
||||
# Same hermeticity contract as test-agent-session-broker-preflight.sh: a fake
|
||||
# tmux on PATH that scripts its own answers, a real unix socket in a tmpdir so
|
||||
# the broker preflight passes, env -i with a fake HOME. No case depends on host
|
||||
# state.
|
||||
#
|
||||
# The failure this suite is written down to catch: during a socket cutover a
|
||||
# seat's session still lives on the DEFAULT tmux socket while the launcher
|
||||
# targets the named one. The declared-socket has-session check cannot see the
|
||||
# legacy session (measured 2026-08-24: rc=1, script proceeds), so launch
|
||||
# creates a same-name duplicate — and comms delivery, which addresses sessions
|
||||
# by NAME, cannot tell the two apart. The guard refuses with its own code
|
||||
# (exit 76, after 75 broker-absent) BEFORE any tmux mutation.
|
||||
#
|
||||
# Cases:
|
||||
# 1. legacy session present -> exit 76, message names seat-on-legacy-socket
|
||||
# + both sockets' roles, and NO tmux session was created.
|
||||
# 2. legacy session absent -> proceeds PAST the guard (the run then stops at
|
||||
# a later precondition; asserted: exit != 76, stderr lacks the guard's
|
||||
# code, proving the guard was not the refusal).
|
||||
# 3. MOSAIC_TMUX_SOCKET empty (single-socket host) -> guard is inert: the
|
||||
# default-socket probe must not fire at all.
|
||||
#
|
||||
# Sabotage control, run by the developer (not in-suite): remove the guard
|
||||
# block, re-run — case 1 fails (exit is not 76), cases 2-3 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-legacy-socket-guard}"
|
||||
FAKE_HOME="$WORK_DIR/home"
|
||||
BIN_DIR="$WORK_DIR/bin"
|
||||
SOCK_DIR="$WORK_DIR/sockets"
|
||||
LOG_FILE="$WORK_DIR/tmux-calls.log"
|
||||
LEGACY_FLAG="$WORK_DIR/legacy-session-present"
|
||||
|
||||
rm -rf "$WORK_DIR"
|
||||
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/lsguard-test.env.generated" <<'ENVEOF'
|
||||
MOSAIC_AGENT_NAME=lsguard-test
|
||||
MOSAIC_GIT_IDENTITY=lsguard-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/lsguard-test.env.generated"
|
||||
|
||||
# A projection with NO named socket, for case 3. Same file minus the socket line.
|
||||
sed '/^MOSAIC_TMUX_SOCKET=/d; s/lsguard-test/lsguard-nosock/' \
|
||||
"$FAKE_HOME/.config/mosaic/fleet/agents/lsguard-test.env.generated" \
|
||||
> "$FAKE_HOME/.config/mosaic/fleet/agents/lsguard-nosock.env.generated"
|
||||
echo 'MOSAIC_TMUX_SOCKET=' >> "$FAKE_HOME/.config/mosaic/fleet/agents/lsguard-nosock.env.generated"
|
||||
chmod 600 "$FAKE_HOME/.config/mosaic/fleet/agents/lsguard-nosock.env.generated"
|
||||
|
||||
# Ownership identity the launcher validates before anything touches tmux:
|
||||
# a 0600 uuid file plus a tmux global environment that matches it exactly.
|
||||
mkdir -p "$FAKE_HOME/.config/mosaic/fleet/run"
|
||||
chmod 750 "$FAKE_HOME/.config/mosaic/fleet/run"
|
||||
OWNER_UUID="aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
|
||||
printf '%s' "$OWNER_UUID" > "$FAKE_HOME/.config/mosaic/fleet/run/holder-owner"
|
||||
chmod 600 "$FAKE_HOME/.config/mosaic/fleet/run/holder-owner"
|
||||
|
||||
# The exact env block assert_owned_tmux_server expects; the socket value differs
|
||||
# per case, so cases rewrite it via write_tmux_env before each run.
|
||||
write_tmux_env() {
|
||||
printf '%s\n' \
|
||||
"HOME=$FAKE_HOME" \
|
||||
'PATH=/usr/bin:/bin' \
|
||||
"PWD=$FAKE_HOME" \
|
||||
"MOSAIC_FLEET_OWNER=$OWNER_UUID" \
|
||||
'MOSAIC_TMUX_HOLDER=_holder' \
|
||||
"MOSAIC_TMUX_SOCKET=$1" > "$WORK_DIR/tmux-env"
|
||||
}
|
||||
|
||||
# ─── Fake tmux ──────────────────────────────────────────────────────────────
|
||||
# Scripted answers: a DEFAULT-socket has-session (argv carries no -L) answers
|
||||
# by the flag file; every named-socket call succeeds (holder present, no
|
||||
# existing session is fine for these cases since refusal happens first).
|
||||
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
|
||||
if [[ "\$*" == *show-environment* ]]; then
|
||||
cat "$WORK_DIR/tmux-env"
|
||||
exit 0
|
||||
fi
|
||||
if [[ "\$*" == *has-session* ]]; then
|
||||
# holder session always present; the seat's DEFAULT-socket presence is the
|
||||
# flag file; the seat is never already-running on the NAMED socket.
|
||||
[[ "\$*" == *_holder* ]] && exit 0
|
||||
if [[ "\$1" == "-L" ]]; then exit 1; fi
|
||||
[[ -e "$LEGACY_FLAG" ]] && exit 0 || exit 1
|
||||
fi
|
||||
exit 0
|
||||
SH
|
||||
chmod +x "$BIN_DIR/tmux"
|
||||
|
||||
for bin in mosaic pi claude; do
|
||||
printf '#!/usr/bin/env bash\nexit 0\n' > "$BIN_DIR/$bin"
|
||||
chmod +x "$BIN_DIR/$bin"
|
||||
done
|
||||
|
||||
# Real socket so the #1292 broker preflight passes and the run reaches the guard.
|
||||
# Same idiom as the broker-preflight suite: AF_UNIX binds cap at 108 path bytes,
|
||||
# so the socket lives at a SHORT /tmp path held by a detached python holder (a
|
||||
# foreground bind would close on exit; -S on a closed-but-unlinked path fails).
|
||||
LIVE_SOCK="/tmp/mosaic-lsguard-$RANDOM-$$.sock"
|
||||
trap 'rm -f "$LIVE_SOCK"' EXIT
|
||||
rm -f "$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" &
|
||||
for _ in $(seq 1 50); do
|
||||
[ -S "$LIVE_SOCK" ] && break
|
||||
sleep 0.1
|
||||
done
|
||||
[ -S "$LIVE_SOCK" ] || { echo "FAIL: could not create live socket" >&2; exit 1; }
|
||||
|
||||
run_session_script() {
|
||||
local agent="$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" \
|
||||
MOSAIC_LEASE_BROKER_SOCKET="$LIVE_SOCK" \
|
||||
"$@" \
|
||||
bash "$SCRIPT_DIR/start-agent-session.sh" "$agent"
|
||||
)
|
||||
}
|
||||
|
||||
fail=0
|
||||
assert() {
|
||||
local desc="$1" expected="$2" actual="$3"
|
||||
[[ "$expected" == "$actual" ]] || { echo "FAIL: $desc — expected '$expected', got '$actual'" >&2; fail=1; }
|
||||
}
|
||||
assert_contains() {
|
||||
local desc="$1" haystack="$2" needle="$3"
|
||||
[[ "$haystack" == *"$needle"* ]] || { echo "FAIL: $desc — missing '$needle'" >&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. Legacy session present → exit 76, no tmux mutation. ─────────────────
|
||||
write_tmux_env "mosaic-fleet"
|
||||
: > "$LOG_FILE"; touch "$LEGACY_FLAG"
|
||||
stderr_file="$WORK_DIR/stderr-1.tmp"
|
||||
set +e
|
||||
run_session_script lsguard-test >/dev/null 2>"$stderr_file"
|
||||
rc=$?
|
||||
set -e
|
||||
err=$(cat "$stderr_file")
|
||||
assert "legacy present exit code" "76" "$rc"
|
||||
assert_contains "names the failure" "$err" "FAIL_LAUNCH seat-on-legacy-socket"
|
||||
assert_contains "names the agent" "$err" "lsguard-test"
|
||||
assert_contains "names the target socket" "$err" "mosaic-fleet"
|
||||
assert_not_contains "no session created" "$(cat "$LOG_FILE")" "TMUX-NEW-SESSION-INVOKED"
|
||||
|
||||
# ─── 2. Legacy session absent → guard is not the refusal. ───────────────────
|
||||
write_tmux_env "mosaic-fleet"
|
||||
: > "$LOG_FILE"; rm -f "$LEGACY_FLAG"
|
||||
stderr_file="$WORK_DIR/stderr-2.tmp"
|
||||
set +e
|
||||
run_session_script lsguard-test >/dev/null 2>"$stderr_file"
|
||||
rc=$?
|
||||
set -e
|
||||
err=$(cat "$stderr_file")
|
||||
if [[ "$rc" == "76" ]]; then
|
||||
echo "FAIL: legacy absent must not exit 76" >&2; fail=1
|
||||
fi
|
||||
assert_not_contains "guard code absent from stderr" "$err" "seat-on-legacy-socket"
|
||||
|
||||
# ─── 3. Empty MOSAIC_TMUX_SOCKET → guard inert, no default-socket probe. ────
|
||||
write_tmux_env ""
|
||||
: > "$LOG_FILE"; touch "$LEGACY_FLAG" # even with a legacy session present
|
||||
stderr_file="$WORK_DIR/stderr-3.tmp"
|
||||
set +e
|
||||
run_session_script lsguard-nosock >/dev/null 2>"$stderr_file"
|
||||
rc=$?
|
||||
set -e
|
||||
err=$(cat "$stderr_file")
|
||||
if [[ "$rc" == "76" ]]; then
|
||||
echo "FAIL: empty socket must never exit 76 (single-socket host)" >&2; fail=1
|
||||
fi
|
||||
assert_not_contains "guard code absent on single-socket host" "$err" "seat-on-legacy-socket"
|
||||
|
||||
rm -f "$LEGACY_FLAG"
|
||||
if [[ "$fail" -ne 0 ]]; then
|
||||
echo "start-agent-session legacy-socket guard regression FAILED" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "start-agent-session legacy-socket guard regression passed"
|
||||
@@ -503,6 +503,28 @@ if [[ -z "$BRANCH" ]]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
# T51 WP5b (spec 4.1, review ruling C4/F8): the declaration adds ROUTE CONTEXT only.
|
||||
# Branch-selection semantics are UNCHANGED — the guard keeps inspecting the
|
||||
# exact head above. No declaration dependency gates the wait (4.3/DR2 R9:
|
||||
# blocking here adds a blocker with no safety gain); absence is silent.
|
||||
# shellcheck source=packages/mosaic/framework/tools/git/ci-queue-wait.sh
|
||||
if git rev-parse --show-toplevel >/dev/null 2>&1 \
|
||||
&& [ -f "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/repo-decl.sh" ]; then
|
||||
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/repo-decl.sh"
|
||||
repo_decl_load
|
||||
if [[ "$DECL_STATE" == invalid ]]; then
|
||||
repo_decl_report_invalid
|
||||
elif [[ "$DECL_STATE" == valid && "$DECL_SCHEMA" == 2 ]]; then
|
||||
route="feature"
|
||||
if [[ "$BRANCH" == "$DECL_TRUNK" ]]; then
|
||||
route="trunk (integration head)"
|
||||
elif [[ "$BRANCH" == "$DECL_RELEASE" ]]; then
|
||||
route="release branch"
|
||||
fi
|
||||
echo "repo-decl: route context flow=$DECL_FLOW trunk=$DECL_TRUNK release=$DECL_RELEASE; guarded head '$BRANCH' is a $route head (spec 4.1)" >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "$PLATFORM" == "github" ]]; then
|
||||
if ! command -v gh >/dev/null 2>&1; then
|
||||
record_cannot_assert "github-cli-unavailable"
|
||||
|
||||
+343
@@ -0,0 +1,343 @@
|
||||
#!/bin/bash
|
||||
# grant-reviewer.sh - Grant a reviewer read + review access to an org-owned
|
||||
# Gitea repository via an org team (default: fleet-reviewers).
|
||||
#
|
||||
# Usage: grant-reviewer.sh -u <user> [-r <owner>/<repo>] [-t <team>]
|
||||
#
|
||||
# The team carries `permission: read` with per-unit overrides
|
||||
# {repo.code: read, repo.issues: write, repo.pulls: write}: the reviewer can
|
||||
# read code and write issues/PR reviews, but cannot push. The grant is
|
||||
# idempotent — the team is looked up before it is created, and member/repo
|
||||
# additions are PUTs.
|
||||
#
|
||||
# KNOWN LIMITATION — branch protection counts these reviews as UNOFFICIAL.
|
||||
# Gitea computes a review's `official` flag at SUBMISSION time, from write
|
||||
# permission on the repo or from membership in the protected branch's
|
||||
# approvals whitelist (disabled by default). A team granted through this
|
||||
# script has read permission on code, so under branch protection with
|
||||
# required_approvals the reviewer's approval shows but does NOT count toward
|
||||
# the required total — the merge still fails with "not enough approvals".
|
||||
# Enabling the approvals whitelist and adding this team to it is review
|
||||
# policy (who counts as an official approver), an operator decision made in
|
||||
# the repo's branch-protection settings, deliberately NOT automated here.
|
||||
# Because `official` is fixed at submission, whitelisting after the fact
|
||||
# requires the review to be re-submitted before it counts.
|
||||
#
|
||||
# Platform: Gitea only. On a GitHub-remoted repo this script refuses to run —
|
||||
# GitHub review access is granted through collaborator/team facilities that
|
||||
# have no equivalent to Gitea's org-team unit map.
|
||||
#
|
||||
# Identity: the acting credential resolves exactly as in issue-comment.sh —
|
||||
# GITEA_LOGIN (when set) names a tea login whose token MUST resolve for the
|
||||
# remote host (fail closed, never downgrade to the host default identity);
|
||||
# otherwise the per-seat identity ladder in detect-platform.sh applies
|
||||
# (MOSAIC_GIT_IDENTITY / git config mosaic.gitIdentity → per-slot token,
|
||||
# fail-loud on fleet hosts). Managing org teams requires org owner/admin:
|
||||
# an HTTP 403 from any step is reported as "org admin required on <org>",
|
||||
# never as a silent partial grant.
|
||||
#
|
||||
# Verification is fail-closed: after the member and repo PUTs, the script
|
||||
# GETs the single resources back (GET /teams/{id}/members/{user} and
|
||||
# GET /teams/{id}/repos/{owner}/{repo}) and refuses to report success unless
|
||||
# both confirm the grant. A PUT that returns success without persisting
|
||||
# (the #865 defect class: an exit code is not evidence of a durable write)
|
||||
# therefore fails the run instead of reporting a grant that does not exist.
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/detect-platform.sh"
|
||||
|
||||
usage() {
|
||||
echo "Usage: grant-reviewer.sh -u <user> [-r <owner>/<repo>] [-t <team>]"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " -u, --user Gitea username to grant reviewer access (required)"
|
||||
echo " -r, --repo Target repository as <owner>/<repo>; defaults to the"
|
||||
echo " current repository's origin. The owner must be an"
|
||||
echo " organization."
|
||||
echo " -t, --team Org team to use/create (default: fleet-reviewers)"
|
||||
echo " -h, --help Show this help"
|
||||
echo ""
|
||||
echo "Environment:"
|
||||
echo " GITEA_LOGIN Override the acting identity with a named tea login"
|
||||
echo " (must resolve for the remote host; fails closed)."
|
||||
echo ""
|
||||
echo "Grants: code read + issues/pulls write via an org team. Gitea only."
|
||||
echo ""
|
||||
echo "LIMITATION: under branch protection with required approvals, reviews"
|
||||
echo "from a read-permission team are official=false and do not count"
|
||||
echo "toward the required total. Making them count means enabling the"
|
||||
echo "protected branch's approvals whitelist and adding the team — an"
|
||||
echo "operator review-policy decision this script does not automate. The"
|
||||
echo "official flag is computed at review submission, so a review made"
|
||||
echo "before whitelisting must be re-submitted afterwards."
|
||||
}
|
||||
|
||||
REVIEWER=""
|
||||
REPO_OVERRIDE=""
|
||||
TEAM="fleet-reviewers"
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
-u|--user)
|
||||
REVIEWER="$2"
|
||||
shift 2
|
||||
;;
|
||||
-r|--repo)
|
||||
REPO_OVERRIDE="$2"
|
||||
shift 2
|
||||
;;
|
||||
-t|--team)
|
||||
TEAM="$2"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$REVIEWER" ]]; then
|
||||
echo "Error: reviewer username is required (-u)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Gitea usernames and team names are AlphaDashDot. Validating here keeps the
|
||||
# values safe to interpolate into API paths without URL-encoding.
|
||||
NAME_RE='^[A-Za-z0-9][A-Za-z0-9._-]*$'
|
||||
if ! [[ "$REVIEWER" =~ $NAME_RE ]]; then
|
||||
echo "Error: invalid reviewer username '$REVIEWER'" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! [[ "$TEAM" =~ $NAME_RE ]]; then
|
||||
echo "Error: invalid team name '$TEAM'" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -n "$REPO_OVERRIDE" ]] && ! [[ "$REPO_OVERRIDE" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*/[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then
|
||||
echo "Error: -r expects <owner>/<repo>, got '$REPO_OVERRIDE'" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
detect_platform >/dev/null
|
||||
|
||||
if [[ "$PLATFORM" != "gitea" ]]; then
|
||||
echo "Error: grant-reviewer.sh is Gitea only (detected platform: $PLATFORM)." >&2
|
||||
echo " On GitHub, grant review access via repository collaborators or org teams in the GitHub UI/CLI." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
HOST=$(get_remote_host) || {
|
||||
echo "Error: could not resolve the remote host from origin" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Acting credential: GITEA_LOGIN (explicit, fail closed) or the identity
|
||||
# ladder. Same ordering contract as issue-comment.sh — an explicit override is
|
||||
# never silently downgraded to the host default identity.
|
||||
if [[ -n "${GITEA_LOGIN:-}" ]]; then
|
||||
GITEA_API_TOKEN=$(get_gitea_token_for_login "$GITEA_LOGIN" "$HOST") || {
|
||||
echo "Error: could not resolve a host-matched Gitea token for GITEA_LOGIN '$GITEA_LOGIN' on host '$HOST'; refusing to fall back to the host default identity (reviewer grant)" >&2
|
||||
exit 1
|
||||
}
|
||||
else
|
||||
GITEA_API_TOKEN=$(get_gitea_token "$HOST") || {
|
||||
echo "Error: no Gitea credential resolved for the acting identity on host '$HOST' (reviewer grant). Set MOSAIC_GIT_IDENTITY=<agent-id>, or set GITEA_LOGIN=<name> to use a named tea credential." >&2
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
|
||||
CONFIGURED_URL=$(get_gitea_url_for_host "$HOST") || {
|
||||
echo "Error: configured Gitea URL not found for host '$HOST'" >&2
|
||||
exit 1
|
||||
}
|
||||
GITEA_API_ROOT="${CONFIGURED_URL%/}/api/v1"
|
||||
|
||||
if [[ -n "$REPO_OVERRIDE" ]]; then
|
||||
REPO_SLUG="$REPO_OVERRIDE"
|
||||
else
|
||||
REPO_SLUG=$(get_gitea_repo_slug_for_url "$CONFIGURED_URL") || {
|
||||
echo "Error: could not resolve <owner>/<repo> from origin; pass -r <owner>/<repo>" >&2
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
ORG="${REPO_SLUG%%/*}"
|
||||
REPO_NAME="${REPO_SLUG#*/}"
|
||||
|
||||
RESPONSE_FILE=$(mktemp "${TMPDIR:-/tmp}/mosaic-grant-reviewer-resp.XXXXXX")
|
||||
AUTH_CONFIG=$(gitea_write_auth_config "$GITEA_API_TOKEN") || {
|
||||
rm -f "$RESPONSE_FILE"
|
||||
echo "Error: could not stage Gitea credential for reviewer grant" >&2
|
||||
exit 1
|
||||
}
|
||||
trap 'rm -f "$RESPONSE_FILE" "$AUTH_CONFIG"' EXIT
|
||||
|
||||
# gitea_api <step> <method> <path> [json-payload]
|
||||
# Runs one API call with the staged credential (token never in argv). Sets
|
||||
# GITEA_API_STATUS and leaves the body in $RESPONSE_FILE. Transport failure
|
||||
# and HTTP 403 are terminal here: 403 on ANY step means the acting identity
|
||||
# cannot manage org teams, and the run must stop rather than continue into a
|
||||
# partial grant.
|
||||
gitea_api() {
|
||||
local step="$1" method="$2" path="$3" payload="${4:-}"
|
||||
local -a payload_args=()
|
||||
if [[ -n "$payload" ]]; then
|
||||
payload_args=(-H 'Content-Type: application/json' -d "$payload")
|
||||
fi
|
||||
if ! GITEA_API_STATUS=$(curl -sS -o "$RESPONSE_FILE" -w '%{http_code}' \
|
||||
-X "$method" \
|
||||
--config "$AUTH_CONFIG" \
|
||||
"${payload_args[@]}" \
|
||||
"$GITEA_API_ROOT$path"); then
|
||||
echo "Error: Gitea transport failed during $step" >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ "$GITEA_API_STATUS" == "403" ]]; then
|
||||
echo "Error: HTTP 403 during $step: org admin required on '$ORG' — managing org teams needs owner/admin on the organization. No grant was completed." >&2
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# json_field <file> <key> — print a top-level scalar field or fail.
|
||||
json_field() {
|
||||
python3 - "$1" "$2" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
|
||||
try:
|
||||
with open(sys.argv[1], encoding="utf-8") as response:
|
||||
data = json.load(response)
|
||||
value = data.get(sys.argv[2]) if isinstance(data, dict) else None
|
||||
if value is None or isinstance(value, (dict, list, bool)):
|
||||
raise ValueError(f"missing or non-scalar field {sys.argv[2]!r}")
|
||||
except (OSError, json.JSONDecodeError, ValueError) as error:
|
||||
print(f"Error: unusable Gitea response: {error}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
print(value)
|
||||
PY
|
||||
}
|
||||
|
||||
# 1. The owner must be an organization: teams are an org facility, and a
|
||||
# user-owned repo would fail later with a misleading team error.
|
||||
gitea_api "organization check" GET "/orgs/$ORG"
|
||||
if [[ "$GITEA_API_STATUS" == "404" ]]; then
|
||||
echo "Error: owner '$ORG' is not an organization on '$HOST'; grant-reviewer requires an org-owned repository" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$GITEA_API_STATUS" != "200" ]]; then
|
||||
echo "Error: organization check for '$ORG' failed with HTTP $GITEA_API_STATUS" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 2. Idempotent team resolution: exact-name lookup first, create only on miss.
|
||||
# The search endpoint substring-matches, so the exact-name filter is done
|
||||
# on the response, not trusted to the query.
|
||||
gitea_api "team lookup" GET "/orgs/$ORG/teams/search?q=$TEAM"
|
||||
if [[ "$GITEA_API_STATUS" != "200" ]]; then
|
||||
echo "Error: team lookup for '$TEAM' on '$ORG' failed with HTTP $GITEA_API_STATUS" >&2
|
||||
exit 1
|
||||
fi
|
||||
TEAM_ID=$(TEAM_NAME="$TEAM" python3 - "$RESPONSE_FILE" <<'PY'
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
wanted = os.environ["TEAM_NAME"]
|
||||
try:
|
||||
with open(sys.argv[1], encoding="utf-8") as response:
|
||||
result = json.load(response)
|
||||
teams = result.get("data") if isinstance(result, dict) else None
|
||||
if not isinstance(teams, list):
|
||||
raise ValueError("team search response carried no data list")
|
||||
except (OSError, json.JSONDecodeError, ValueError) as error:
|
||||
print(f"Error: unusable team search response: {error}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
for team in teams:
|
||||
if isinstance(team, dict) and team.get("name") == wanted:
|
||||
team_id = team.get("id")
|
||||
if not isinstance(team_id, int) or team_id <= 0:
|
||||
print("Error: matched team carried no positive id", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
print(team_id)
|
||||
raise SystemExit(0)
|
||||
print("")
|
||||
PY
|
||||
)
|
||||
|
||||
if [[ -z "$TEAM_ID" ]]; then
|
||||
CREATE_PAYLOAD=$(TEAM_NAME="$TEAM" python3 -c '
|
||||
import json
|
||||
import os
|
||||
|
||||
print(json.dumps({
|
||||
"name": os.environ["TEAM_NAME"],
|
||||
"description": "review seats: code read + issues/pulls write",
|
||||
"permission": "read",
|
||||
"includes_all_repositories": False,
|
||||
"can_create_org_repo": False,
|
||||
"units_map": {
|
||||
"repo.code": "read",
|
||||
"repo.issues": "write",
|
||||
"repo.pulls": "write",
|
||||
},
|
||||
}))
|
||||
')
|
||||
gitea_api "team create" POST "/orgs/$ORG/teams" "$CREATE_PAYLOAD"
|
||||
if [[ "$GITEA_API_STATUS" != "201" ]]; then
|
||||
echo "Error: team create for '$TEAM' on '$ORG' failed with HTTP $GITEA_API_STATUS" >&2
|
||||
exit 1
|
||||
fi
|
||||
TEAM_ID=$(json_field "$RESPONSE_FILE" id) || {
|
||||
echo "Error: team create returned no usable team id" >&2
|
||||
exit 1
|
||||
}
|
||||
echo "Created team '$TEAM' (id $TEAM_ID) on org '$ORG'"
|
||||
else
|
||||
echo "Found existing team '$TEAM' (id $TEAM_ID) on org '$ORG'"
|
||||
fi
|
||||
|
||||
# 3. Membership and repo attachment — both PUTs, both idempotent in Gitea.
|
||||
gitea_api "member add" PUT "/teams/$TEAM_ID/members/$REVIEWER"
|
||||
if [[ "$GITEA_API_STATUS" != "204" ]]; then
|
||||
echo "Error: adding '$REVIEWER' to team '$TEAM' failed with HTTP $GITEA_API_STATUS" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
gitea_api "repo add" PUT "/teams/$TEAM_ID/repos/$ORG/$REPO_NAME"
|
||||
if [[ "$GITEA_API_STATUS" != "204" ]]; then
|
||||
echo "Error: adding repo '$REPO_SLUG' to team '$TEAM' failed with HTTP $GITEA_API_STATUS" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 4. Fail-closed read-back: a 204 from a PUT is an exit code, not evidence the
|
||||
# grant persisted. GET the single resources back and require both.
|
||||
gitea_api "member read-back" GET "/teams/$TEAM_ID/members/$REVIEWER"
|
||||
if [[ "$GITEA_API_STATUS" != "200" ]]; then
|
||||
echo "Error: reviewer grant NOT verified — GET /teams/$TEAM_ID/members/$REVIEWER returned HTTP $GITEA_API_STATUS after a successful PUT. Treat the grant as not made." >&2
|
||||
exit 1
|
||||
fi
|
||||
READBACK_LOGIN=$(json_field "$RESPONSE_FILE" login) || exit 1
|
||||
if [[ "${READBACK_LOGIN,,}" != "${REVIEWER,,}" ]]; then
|
||||
echo "Error: reviewer grant NOT verified — member read-back returned login '$READBACK_LOGIN', expected '$REVIEWER'" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
gitea_api "repo read-back" GET "/teams/$TEAM_ID/repos/$ORG/$REPO_NAME"
|
||||
if [[ "$GITEA_API_STATUS" != "200" ]]; then
|
||||
echo "Error: reviewer grant NOT verified — GET /teams/$TEAM_ID/repos/$ORG/$REPO_NAME returned HTTP $GITEA_API_STATUS after a successful PUT. Treat the grant as not made." >&2
|
||||
exit 1
|
||||
fi
|
||||
READBACK_FULL_NAME=$(json_field "$RESPONSE_FILE" full_name) || exit 1
|
||||
if [[ "${READBACK_FULL_NAME,,}" != "${REPO_SLUG,,}" ]]; then
|
||||
echo "Error: reviewer grant NOT verified — repo read-back returned '$READBACK_FULL_NAME', expected '$REPO_SLUG'" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Granted: '$REVIEWER' is a member of team '$TEAM' (id $TEAM_ID) with access to '$REPO_SLUG' (code read, issues/pulls write) — verified by read-back"
|
||||
echo "Note: under branch protection with required approvals this reviewer's approvals are official=false unless the branch's approvals whitelist includes the team (operator decision; reviews submitted before whitelisting must be re-submitted)."
|
||||
@@ -192,6 +192,47 @@ cmd_new() {
|
||||
local path; path="$(derive_path "$branch")"
|
||||
assert_not_home "$path"
|
||||
|
||||
# T51 WP5b (spec 4.4 staged rule + 4.5 advisory policy): placement stays
|
||||
# DERIVED; the declaration never moves the worktree. An INVALID declaration
|
||||
# fails branch-creation loud (a broken structure file must not ride a new
|
||||
# branch); an absent one warns (rollout window, Q-C); a valid one contributes
|
||||
# policy ADVICE only. The advisory worktree_root comparison runs only when
|
||||
# MOSAIC_HOST_ROOT is set (1.2a: warn-and-omit for advisory display).
|
||||
# shellcheck source=packages/mosaic/framework/tools/git/repo-decl.sh
|
||||
_rd="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)/repo-decl.sh"
|
||||
if [ -f ""$_rd"" ]; then
|
||||
source ""$_rd""
|
||||
repo_decl_load
|
||||
else
|
||||
DECL_STATE=absent; DECL_SCHEMA=""
|
||||
repo_decl_warn() { printf 'repo-decl: %s\n' "$*" >&2; }
|
||||
repo_decl_report_invalid() { :; }
|
||||
repo_decl_warn_absent_reversible() { :; }
|
||||
repo_decl_warn_absent_irreversible() { :; }
|
||||
repo_decl_remote_matches() { return 0; }
|
||||
repo_decl_check_transition() { return 2; }
|
||||
fi
|
||||
case "$DECL_STATE" in
|
||||
invalid)
|
||||
repo_decl_report_invalid
|
||||
die "worktree branch-creation refused: the structure declaration is invalid (spec 4.4 — fix it before creating branches)"
|
||||
;;
|
||||
absent)
|
||||
repo_decl_warn_absent_irreversible "worktree branch-creation"
|
||||
;;
|
||||
valid)
|
||||
if [ "$DECL_SCHEMA" = 2 ] && [ "$DECL_POLICY" = "orchestrator-precreated" ]; then
|
||||
echo "repo-decl: worktree_policy=orchestrator-precreated (TRANSITIONAL, spec 4.5): tasking pre-creates worktrees; creating one directly is the interim path until the J3/#1174 amendment unblocks the wrapper consumer." >&2
|
||||
fi
|
||||
if [ -n "${MOSAIC_HOST_ROOT:-}" ] && [ -n "$DECL_WT_ROOT" ]; then
|
||||
dwt="$(repo_decl_path "$DECL_WT_ROOT" 2>/dev/null || true)"
|
||||
if [ -n "$dwt" ] && [ "${dwt%/}" != "${WT_ROOT%/}" ]; then
|
||||
echo "repo-decl: derived root $WT_ROOT diverges from the declared advisory worktree_root $dwt (advisory per spec 4.1/5.2 — placement stays derived)" >&2
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ -e "$path" ]; then
|
||||
echo "exists: $path"
|
||||
echo "(already checked out — reuse it, or 'rm' it first)"
|
||||
|
||||
@@ -78,8 +78,8 @@ gitea_pr_create_api() {
|
||||
# historical "main" literal, which mistargeted every fallback PR on
|
||||
# repos whose trunk is not main (e.g. mosaicstack/stack -> next).
|
||||
local api_base=""
|
||||
if [[ -n "$BASE_BRANCH" ]]; then
|
||||
api_base="$BASE_BRANCH"
|
||||
if [[ -n "$EFFECTIVE_BASE" ]]; then
|
||||
api_base="$EFFECTIVE_BASE"
|
||||
else
|
||||
api_base=$(gitea_default_branch) || {
|
||||
echo "Error: could not resolve the forge default branch for the API-fallback base; pass -B <branch> explicitly" >&2
|
||||
@@ -198,6 +198,52 @@ if [[ -z "$HEAD_BRANCH" ]]; then
|
||||
HEAD_BRANCH=$(git branch --show-current)
|
||||
fi
|
||||
|
||||
# T51 WP5b: declaration-driven base resolution (spec 4.1). Precedence:
|
||||
# explicit -B -> validated as an ALLOWED transition (4.2: a flag is input,
|
||||
# not authority) when a consumable declaration exists
|
||||
# declared trunk (v2 declarations only) -> used directly
|
||||
# legacy -> WP5a forge-default floor (unmanaged/absent/v1, 4.3)
|
||||
# shellcheck source=packages/mosaic/framework/tools/git/repo-decl.sh
|
||||
if [ -f "$SCRIPT_DIR/repo-decl.sh" ]; then
|
||||
source "$SCRIPT_DIR/repo-decl.sh"
|
||||
repo_decl_load
|
||||
else
|
||||
DECL_STATE=absent; DECL_SCHEMA=""
|
||||
repo_decl_warn() { printf 'repo-decl: %s\n' "$*" >&2; }
|
||||
repo_decl_report_invalid() { :; }
|
||||
repo_decl_warn_absent_reversible() { :; }
|
||||
repo_decl_warn_absent_irreversible() { :; }
|
||||
repo_decl_remote_matches() { return 0; }
|
||||
repo_decl_check_transition() { return 2; }
|
||||
fi
|
||||
EFFECTIVE_BASE="$BASE_BRANCH"
|
||||
case "$DECL_STATE" in
|
||||
invalid) repo_decl_report_invalid ;;
|
||||
esac
|
||||
if [[ "$DECL_STATE" == valid && "$DECL_SCHEMA" != 2 ]]; then
|
||||
repo_decl_warn "declaration is v$DECL_SCHEMA — carries no consumable flow/trunk fields; legacy behavior"
|
||||
fi
|
||||
if [[ "$DECL_STATE" == valid && "$DECL_SCHEMA" == 2 ]]; then
|
||||
# Write path: a normalized-remote mismatch refuses (spec 5.3).
|
||||
if ! repo_decl_remote_matches; then
|
||||
echo "Error: origin remote does not match the declared canonical_remote (spec 5.3, write path) — refusing to create a PR against the wrong forge. Fix the origin remote or the declaration." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -n "$BASE_BRANCH" ]]; then
|
||||
trc=0
|
||||
repo_decl_check_transition "$HEAD_BRANCH" "$BASE_BRANCH" || trc=$?
|
||||
if [[ "$trc" == 1 ]]; then
|
||||
echo "Error: -B '$BASE_BRANCH' is not an allowed transition for head '$HEAD_BRANCH' under the declared flow (spec 4.2). The declaration governs; supply an allowed base." >&2
|
||||
exit 1
|
||||
fi
|
||||
# trc 2 cannot happen here (state=valid): 0 = allowed
|
||||
else
|
||||
EFFECTIVE_BASE="$DECL_TRUNK"
|
||||
fi
|
||||
elif [[ -z "$BASE_BRANCH" ]]; then
|
||||
repo_decl_warn_absent_reversible "pr-create"
|
||||
fi
|
||||
|
||||
# Add issue reference to body if provided
|
||||
if [[ -n "$ISSUE" ]]; then
|
||||
if [[ -n "$BODY" ]]; then
|
||||
@@ -215,7 +261,7 @@ case "$PLATFORM" in
|
||||
github)
|
||||
CMD=(gh pr create --title "$TITLE")
|
||||
[[ -n "$BODY" ]] && CMD+=(--body "$BODY")
|
||||
[[ -n "$BASE_BRANCH" ]] && CMD+=(--base "$BASE_BRANCH")
|
||||
[[ -n "$EFFECTIVE_BASE" ]] && CMD+=(--base "$EFFECTIVE_BASE")
|
||||
[[ -n "$HEAD_BRANCH" ]] && CMD+=(--head "$HEAD_BRANCH")
|
||||
[[ -n "$LABELS" ]] && CMD+=(--label "$LABELS")
|
||||
[[ -n "$MILESTONE" ]] && CMD+=(--milestone "$MILESTONE")
|
||||
@@ -240,7 +286,7 @@ case "$PLATFORM" in
|
||||
REPO_ARGS=(--repo "$REPO_SLUG" --login "$GITEA_LOGIN_NAME")
|
||||
CMD=(tea pr create "${REPO_ARGS[@]}" --title "$TITLE")
|
||||
[[ -n "$BODY" ]] && CMD+=(--description "$BODY")
|
||||
[[ -n "$BASE_BRANCH" ]] && CMD+=(--base "$BASE_BRANCH")
|
||||
[[ -n "$EFFECTIVE_BASE" ]] && CMD+=(--base "$EFFECTIVE_BASE")
|
||||
[[ -n "$HEAD_BRANCH" ]] && CMD+=(--head "$HEAD_BRANCH")
|
||||
|
||||
# Handle labels for tea
|
||||
|
||||
@@ -137,9 +137,44 @@ HEAD_REPO="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; value=js
|
||||
BASE_REPO="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; value=json.load(sys.stdin).get("baseRepository") or ""; print((value.get("nameWithOwner") or value.get("full_name") or "") if isinstance(value, dict) else str(value).strip())')"
|
||||
PR_TITLE="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("title") or "").strip())')"
|
||||
PR_AUTHOR="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; value=json.load(sys.stdin).get("author") or ""; print((value.get("login") or "").strip() if isinstance(value, dict) else str(value).strip())')"
|
||||
if [[ "$BASE_BRANCH" != "main" && "$BASE_BRANCH" != "next" ]]; then
|
||||
echo "Error: Mosaic policy allows merges only for PRs targeting 'main' or 'next' (found '$BASE_BRANCH')." >&2
|
||||
exit 1
|
||||
# T51 WP5b: transition validation against the declaration (spec 4.1/4.2).
|
||||
# Target branches are validated against the declaration, NEVER hardcoded;
|
||||
# the legacy main/next check survives only for undeclared repos during the
|
||||
# rollout window (4.3 irreversible class, loud warning).
|
||||
# shellcheck source=packages/mosaic/framework/tools/git/repo-decl.sh
|
||||
if [ -f "$SCRIPT_DIR/repo-decl.sh" ]; then
|
||||
source "$SCRIPT_DIR/repo-decl.sh"
|
||||
repo_decl_load
|
||||
else
|
||||
DECL_STATE=absent; DECL_SCHEMA=""
|
||||
repo_decl_warn() { printf 'repo-decl: %s\n' "$*" >&2; }
|
||||
repo_decl_report_invalid() { :; }
|
||||
repo_decl_warn_absent_reversible() { :; }
|
||||
repo_decl_warn_absent_irreversible() { :; }
|
||||
repo_decl_remote_matches() { return 0; }
|
||||
repo_decl_check_transition() { return 2; }
|
||||
fi
|
||||
if [[ "$DECL_STATE" == invalid ]]; then
|
||||
repo_decl_report_invalid
|
||||
fi
|
||||
if [[ "$DECL_STATE" == valid && "$DECL_SCHEMA" == 2 ]]; then
|
||||
if ! repo_decl_remote_matches; then
|
||||
echo "Error: origin remote does not match the declared canonical_remote (spec 5.3, write path) — refusing to merge against the wrong forge. Fix the origin remote or the declaration." >&2
|
||||
exit 1
|
||||
fi
|
||||
trc=0
|
||||
repo_decl_check_transition "$HEAD_BRANCH" "$BASE_BRANCH" || trc=$?
|
||||
if [[ "$trc" == 1 ]]; then
|
||||
echo "Error: PR '$HEAD_BRANCH' -> '$BASE_BRANCH' is not a declared transition (flow=$DECL_FLOW, trunk=$DECL_TRUNK, release=$DECL_RELEASE; spec 4.2)." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "repo-decl: transition OK under flow=$DECL_FLOW (trunk=$DECL_TRUNK release=$DECL_RELEASE)" >&2
|
||||
else
|
||||
repo_decl_warn_absent_irreversible "pr-merge"
|
||||
if [[ "$BASE_BRANCH" != "main" && "$BASE_BRANCH" != "next" ]]; then
|
||||
echo "Error: Mosaic policy allows merges only for PRs targeting 'main' or 'next' (found '$BASE_BRANCH')." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
if [[ -z "$HEAD_BRANCH" || -z "$HEAD_REPO" || ! "$HEAD_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then
|
||||
echo "Error: Could not resolve the PR head branch, repository, and full commit SHA for queue inspection." >&2
|
||||
|
||||
+198
@@ -0,0 +1,198 @@
|
||||
#!/usr/bin/env bash
|
||||
# repo-decl.sh — shared .mosaic/repo.json consumption for the git wrappers (T51 WP5b).
|
||||
#
|
||||
# Spec of record: docs/plans/2026-08-23_repo-structure-declaration.md (brain
|
||||
# repo) sections 4 (consumption contract), 5.3 (normalization), 5.4
|
||||
# (enforcement points), 1.2a (root anchoring). ALL consumers invoke the SAME
|
||||
# WP1 validator (spec 5.1 — no in-process-only parsing of the declaration).
|
||||
#
|
||||
# Source this file, then call repo_decl_load once. It sets:
|
||||
# DECL_STATE absent | invalid | valid
|
||||
# DECL_FILE the declaration path that was inspected
|
||||
# DECL_ERROR the validator's error line when DECL_STATE=invalid
|
||||
# DECL_TRUNK / DECL_RELEASE / DECL_FLOW / DECL_REMOTE / DECL_POLICY /
|
||||
# DECL_WT_ROOT / DECL_CLONE (populated only when DECL_STATE=valid)
|
||||
# DECL_ORIGIN_N the normalized origin URL (when resolvable)
|
||||
#
|
||||
# Enforcement point 5.4(1): an invalid or unknown-version file counts as
|
||||
# ABSENT for behavior, PLUS a loud error naming the file and the validator's
|
||||
# key/reason — callers print DECL_ERROR (repo_decl_report_invalid) whenever
|
||||
# they loaded something that failed validation; they do not silently ignore a
|
||||
# broken file.
|
||||
#
|
||||
# Absence behavior (4.3) is the CALLER's policy (reversible vs irreversible;
|
||||
# managed vs unmanaged — the adoption register is WP6, so during rollout every
|
||||
# repo is unmanaged: warn + legacy). Helpers below provide the shared wordings.
|
||||
#
|
||||
# Root-dependent fields: consumers here read branch/flow/remote/policy only —
|
||||
# NO path resolution happens in this library. The one helper that would
|
||||
# resolve a host:/ path (repo_decl_path) fails closed while MOSAIC_HOST_ROOT
|
||||
# is unset (1.2a: never guess a root), for any future caller that needs it.
|
||||
#
|
||||
# No output on success; diagnostics go to stderr.
|
||||
|
||||
REPO_DECL_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_DECL_VALIDATOR="$REPO_DECL_SCRIPT_DIR/../structure/validate-repo-json.sh"
|
||||
|
||||
repo_decl_warn() { printf 'repo-decl: %s\n' "$*" >&2; }
|
||||
|
||||
# Load and classify the declaration for the repo containing the current
|
||||
# directory. Never fatal — classification is the product.
|
||||
repo_decl_load() {
|
||||
DECL_STATE=absent
|
||||
DECL_FILE=""
|
||||
DECL_ERROR=""
|
||||
DECL_SCHEMA=""
|
||||
DECL_TRUNK=""; DECL_RELEASE=""; DECL_FLOW=""; DECL_REMOTE=""
|
||||
DECL_POLICY=""; DECL_WT_ROOT=""; DECL_CLONE=""
|
||||
DECL_ORIGIN_N=""
|
||||
|
||||
local root
|
||||
root="$(git rev-parse --show-toplevel 2>/dev/null)" || {
|
||||
repo_decl_warn "no git repository — declaration consumption skipped"
|
||||
return 0
|
||||
}
|
||||
DECL_FILE="$root/.mosaic/repo.json"
|
||||
[ -f "$DECL_FILE" ] || return 0
|
||||
|
||||
if [ ! -x "$REPO_DECL_VALIDATOR" ] && [ ! -f "$REPO_DECL_VALIDATOR" ]; then
|
||||
# 5.1 mandates the shared validator; a missing validator is an
|
||||
# infrastructure failure, not an absent declaration.
|
||||
repo_decl_warn "validator not found at $REPO_DECL_VALIDATOR — treating declaration as invalid"
|
||||
DECL_STATE=invalid
|
||||
DECL_ERROR="VALIDATION_ERROR validator: the shared validator is missing"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local vout
|
||||
if ! vout="$("$REPO_DECL_VALIDATOR" "$DECL_FILE" --mode display 2>&1)"; then
|
||||
DECL_STATE=invalid
|
||||
DECL_ERROR="$(printf '%s\n' "$vout" | grep -m1 'VALIDATION_ERROR' || printf '%s\n' "$vout" | head -1)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Valid: extract the consumed fields via the same python stdlib the
|
||||
# ecosystem already uses. Field-level grammar was the validator's job.
|
||||
eval "$(python3 - "$DECL_FILE" <<'PY'
|
||||
import json, sys
|
||||
d = json.load(open(sys.argv[1]))
|
||||
def q(k):
|
||||
v = d.get(k, "")
|
||||
return v if isinstance(v, str) else ""
|
||||
sv = d.get("schema_version", 1)
|
||||
print(f"DECL_SCHEMA={sv if isinstance(sv, int) and not isinstance(sv, bool) else 0!r}")
|
||||
print(f"DECL_TRUNK={q('integration_trunk')!r}")
|
||||
print(f"DECL_RELEASE={q('release_branch')!r}")
|
||||
print(f"DECL_FLOW={q('flow')!r}")
|
||||
print(f"DECL_REMOTE={q('canonical_remote')!r}")
|
||||
print(f"DECL_POLICY={q('worktree_policy')!r}")
|
||||
print(f"DECL_WT_ROOT={q('worktree_root')!r}")
|
||||
print(f"DECL_CLONE={q('canonical_clone')!r}")
|
||||
PY
|
||||
)" || {
|
||||
DECL_STATE=invalid
|
||||
DECL_ERROR="VALIDATION_ERROR internal: field extraction failed"
|
||||
return 0
|
||||
}
|
||||
DECL_STATE=valid
|
||||
|
||||
# 5.3: normalize origin once for remote comparisons (read callers warn,
|
||||
# write callers refuse). An unresolvable origin is left empty — callers
|
||||
# treat empty as "cannot compare" and act per their read/write policy.
|
||||
local ourl
|
||||
if ourl="$(git remote get-url origin 2>/dev/null)" && [ -n "$ourl" ]; then
|
||||
DECL_ORIGIN_N="$("$REPO_DECL_VALIDATOR" --normalize-remote "$ourl" 2>/dev/null || true)"
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# The mandatory loud error for an invalid file (5.4 point 1). Callers invoke
|
||||
# this whenever DECL_STATE=invalid, regardless of their proceed/refuse policy.
|
||||
repo_decl_report_invalid() {
|
||||
repo_decl_warn "declaration INVALID at $DECL_FILE — $DECL_ERROR"
|
||||
repo_decl_warn "treating the declaration as ABSENT (spec 5.4); legacy behavior follows"
|
||||
}
|
||||
|
||||
# Shared absence wordings (4.3, rollout window: no adoption register yet, so
|
||||
# every repo is unmanaged; warn + legacy per the Q-C ruling).
|
||||
repo_decl_warn_absent_reversible() { # $1 = operation name
|
||||
repo_decl_warn "no .mosaic/repo.json — $1 is unmanaged during rollout: legacy behavior, no declaration guarantees (spec 4.3)"
|
||||
}
|
||||
repo_decl_warn_absent_irreversible() { # $1 = operation name
|
||||
repo_decl_warn "no .mosaic/repo.json — $1 proceeds on LEGACY assumptions during the rollout window; declaration-validated transitions unavailable (spec 4.3)"
|
||||
}
|
||||
|
||||
# Remote comparison (5.3). rc 0 match/unknown, rc 1 mismatch.
|
||||
repo_decl_remote_matches() {
|
||||
[ "$DECL_STATE" = valid ] || return 0
|
||||
[ -n "$DECL_ORIGIN_N" ] && [ -n "$DECL_REMOTE" ] || return 0
|
||||
local want
|
||||
want="$("$REPO_DECL_VALIDATOR" --normalize-remote "$DECL_REMOTE" 2>/dev/null || true)"
|
||||
[ -n "$want" ] || return 0
|
||||
[ "$DECL_ORIGIN_N" = "$want" ]
|
||||
}
|
||||
|
||||
# Resolve a host:/-anchored declaration path (1.2a). Fails CLOSED while
|
||||
# MOSAIC_HOST_ROOT is unset or empty — never guesses a root. No WP5b consumer
|
||||
# calls this today; it exists so the first one that needs a path cannot
|
||||
# silently guess.
|
||||
repo_decl_path() { # $1 = host:/... value; prints the resolved absolute path
|
||||
local v="${1:-}" root="${MOSAIC_HOST_ROOT:-}"
|
||||
case "$v" in
|
||||
host:/*) ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
if [ -z "$root" ]; then
|
||||
repo_decl_warn "MOSAIC_HOST_ROOT is unset — refusing to resolve '$v' (spec 1.2a fail-closed; never guess a root)"
|
||||
return 1
|
||||
fi
|
||||
printf '%s/%s\n' "${root%/}" "${v#host:/}"
|
||||
}
|
||||
|
||||
# Transition validation (4.2: a CLI flag is input, not authority).
|
||||
# rc 0 = allowed; rc 1 = forbidden (message on stderr); rc 2 = no valid
|
||||
# declaration (caller applies its absence policy).
|
||||
# flow=direct: base must be the trunk (trunk == release); head must
|
||||
# differ from it.
|
||||
# flow=trunk-release: feature->trunk allowed; trunk->release allowed (release
|
||||
# promotion: head IS the trunk); anything else refused —
|
||||
# feature->release explicitly REJECTED.
|
||||
repo_decl_check_transition() { # $1 head, $2 base
|
||||
[ "$DECL_STATE" = valid ] || return 2
|
||||
local head="$1" base="$2"
|
||||
if [ -z "$head" ] || [ -z "$base" ]; then
|
||||
repo_decl_warn "transition check needs a head and a base (got head='$head' base='$base')"
|
||||
return 1
|
||||
fi
|
||||
if [ "$head" = "$base" ]; then
|
||||
repo_decl_warn "forbidden transition: head '$head' equals base '$base'"
|
||||
return 1
|
||||
fi
|
||||
case "$DECL_FLOW" in
|
||||
direct)
|
||||
if [ "$base" = "$DECL_TRUNK" ]; then
|
||||
return 0
|
||||
fi
|
||||
repo_decl_warn "forbidden transition (flow=direct): base must be the trunk '$DECL_TRUNK', got '$base'"
|
||||
return 1
|
||||
;;
|
||||
trunk-release)
|
||||
if [ "$base" = "$DECL_TRUNK" ] && [ "$head" != "$DECL_TRUNK" ] && [ "$head" != "$DECL_RELEASE" ]; then
|
||||
return 0 # feature -> trunk
|
||||
fi
|
||||
if [ "$head" = "$DECL_TRUNK" ] && [ "$base" = "$DECL_RELEASE" ]; then
|
||||
return 0 # release promotion: trunk -> release
|
||||
fi
|
||||
if [ "$base" = "$DECL_RELEASE" ] && [ "$head" != "$DECL_TRUNK" ]; then
|
||||
repo_decl_warn "forbidden transition (flow=trunk-release): feature->release is REJECTED (head '$head' -> release '$DECL_RELEASE'); promote via $DECL_TRUNK"
|
||||
return 1
|
||||
fi
|
||||
repo_decl_warn "forbidden transition (flow=trunk-release): '$head' -> '$base' is not a declared transition (feature->$DECL_TRUNK or $DECL_TRUNK->$DECL_RELEASE)"
|
||||
return 1
|
||||
;;
|
||||
*)
|
||||
repo_decl_warn "unknown declared flow '$DECL_FLOW'"
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
+616
@@ -0,0 +1,616 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regression harness for grant-reviewer.sh (#1415): org-team reviewer grant
|
||||
# with fail-closed read-back verification.
|
||||
#
|
||||
# This harness models a REAL server: the curl stub keeps persistent team/
|
||||
# member/repo state on disk, the POST actually CREATES and PERSISTS the team,
|
||||
# the member/repo PUTs persist (except in the sabotage modes), and the
|
||||
# read-back GETs answer from that same state. There is no fabricated record
|
||||
# for the wrapper to "find" — verification passes only if the PUTs genuinely
|
||||
# persisted what the read-back retrieves. It proves the wrapper:
|
||||
# 1. creates the team with the EXACT reviewer payload (permission: read,
|
||||
# units_map {repo.code: read, repo.issues: write, repo.pulls: write}) —
|
||||
# the stub rejects any other payload;
|
||||
# 2. is idempotent: an existing team is found by EXACT name (a decoy team
|
||||
# whose name merely CONTAINS the wanted name is listed first and must
|
||||
# not be matched) and no create POST is issued;
|
||||
# 3. refuses to run against a GitHub-remoted repo (Gitea only);
|
||||
# 4. refuses when the owner is not an organization;
|
||||
# 5. maps HTTP 403 to "org admin required on <org>" and stops before any
|
||||
# partial grant;
|
||||
# 6. fails closed when the member PUT returns 204 without persisting (the
|
||||
# #865 defect class: an exit code is not evidence of a durable write);
|
||||
# 7. fails closed when the repo PUT returns 204 without persisting;
|
||||
# 8. with GITEA_LOGIN set, performs EVERY request under that login's token
|
||||
# (never the host default), and with an UNRESOLVABLE GITEA_LOGIN fails
|
||||
# closed with ZERO API calls instead of downgrading;
|
||||
# 9. never lets the bearer token ride in curl argv (curl --config only);
|
||||
# 10. leaves no temp files behind on success or failure paths.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/grant-reviewer}"
|
||||
REPO_DIR="$WORK_DIR/repo"
|
||||
GH_REPO_DIR="$WORK_DIR/gh-repo"
|
||||
BIN_DIR="$WORK_DIR/bin"
|
||||
XDG_DIR="$WORK_DIR/xdg"
|
||||
TEA_LOG="$WORK_DIR/tea.log"
|
||||
CURL_LOG="$WORK_DIR/curl.log"
|
||||
# Full curl argv per invocation — proves the bearer token never rides in argv.
|
||||
CURL_ARGV_LOG="$WORK_DIR/curl-argv.log"
|
||||
AUTH_LOG="$WORK_DIR/auth.log"
|
||||
OUTPUT_FILE="$WORK_DIR/output.log"
|
||||
CREDENTIALS_FILE="$WORK_DIR/credentials.json"
|
||||
STATE_FILE="$WORK_DIR/grants.json"
|
||||
PAYLOAD_VIOLATION_FILE="$WORK_DIR/payload-violation"
|
||||
TMP_SCRATCH="$WORK_DIR/scratch"
|
||||
HOME_DIR="$WORK_DIR/home"
|
||||
|
||||
cleanup() {
|
||||
rm -rf "$WORK_DIR"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
mkdir -p "$REPO_DIR" "$GH_REPO_DIR" "$BIN_DIR" "$XDG_DIR" "$TMP_SCRATCH" "$HOME_DIR"
|
||||
git -C "$REPO_DIR" init -q
|
||||
git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
|
||||
git -C "$GH_REPO_DIR" init -q
|
||||
git -C "$GH_REPO_DIR" remote add origin https://github.com/someorg/somerepo.git
|
||||
# HERMETICITY (#1007): get_gitea_token() step 0 resolves a per-agent identity
|
||||
# from `git config --get mosaic.gitIdentity`, which on a provisioned seat is
|
||||
# set GLOBALLY and leaks into this fresh repo, after which a REAL per-slot
|
||||
# token is read from $HOME and the fixture credential is silently ignored. An
|
||||
# empty repo-local value shadows the global one and reads back empty at rc=0.
|
||||
# (The env-var route does NOT neutralize step 0's git-config read — but the
|
||||
# run env below still pins MOSAIC_GIT_IDENTITY= empty so the ENV rung of the
|
||||
# ladder cannot resolve either: `${MOSAIC_GIT_IDENTITY:-}` treats set-but-empty
|
||||
# as unset.)
|
||||
git -C "$REPO_DIR" config mosaic.gitIdentity ""
|
||||
git -C "$GH_REPO_DIR" config mosaic.gitIdentity ""
|
||||
|
||||
ORG="mosaicstack"
|
||||
REPO_SLUG="mosaicstack/stack"
|
||||
API_ROOT="https://git.mosaicstack.dev/api/v1"
|
||||
REVIEWER="rev-user"
|
||||
TEAM_NAME="fleet-reviewers"
|
||||
TEAM_ID=42
|
||||
DECOY_TEAM_ID=99
|
||||
DEFAULT_TOKEN="test-only-placeholder"
|
||||
DEFAULT_IDENTITY="seat-default"
|
||||
OVERRIDE_LOGIN="granter"
|
||||
OVERRIDE_TOKEN="override-token-placeholder"
|
||||
|
||||
# tea config: the GITEA_LOGIN override login has its own host-bound token here.
|
||||
mkdir -p "$XDG_DIR/tea"
|
||||
OVERRIDE_LOGIN="$OVERRIDE_LOGIN" OVERRIDE_TOKEN="$OVERRIDE_TOKEN" \
|
||||
python3 - "$XDG_DIR/tea/config.yml" <<'PY'
|
||||
import os
|
||||
import sys
|
||||
|
||||
with open(sys.argv[1], "w", encoding="utf-8") as handle:
|
||||
handle.write("logins:\n")
|
||||
handle.write(f" - name: {os.environ['OVERRIDE_LOGIN']}\n")
|
||||
handle.write(" url: https://git.mosaicstack.dev\n")
|
||||
handle.write(f" token: {os.environ['OVERRIDE_TOKEN']}\n")
|
||||
PY
|
||||
|
||||
CONFIGURED_GITEA_URL="https://git.mosaicstack.dev" python3 - "$CREDENTIALS_FILE" <<'PY'
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
with open(sys.argv[1], "w", encoding="utf-8") as credentials:
|
||||
json.dump({
|
||||
"gitea": {
|
||||
"mosaicstack": {
|
||||
"url": os.environ["CONFIGURED_GITEA_URL"],
|
||||
"token": "test-only-placeholder",
|
||||
}
|
||||
}
|
||||
}, credentials)
|
||||
PY
|
||||
|
||||
# tea stub: grant-reviewer.sh must never shell out to tea at all.
|
||||
cat > "$BIN_DIR/tea" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
printf '%s\n' "$*" >> "$GRANT_REVIEWER_TEA_LOG"
|
||||
echo "Unexpected tea command (grant-reviewer must not use tea): $*" >&2
|
||||
exit 92
|
||||
SH
|
||||
chmod +x "$BIN_DIR/tea"
|
||||
|
||||
# curl stub: a small REST server backed by persistent on-disk grant state.
|
||||
# GET /orgs/{org} -> org existence (404 in not-an-org mode)
|
||||
# GET /orgs/{org}/teams/search -> teams from state (decoy always listed FIRST)
|
||||
# POST /orgs/{org}/teams -> validate EXACT payload, CREATE + PERSIST
|
||||
# PUT /teams/{id}/members/{user} -> 204; persists unless member-put-noop
|
||||
# PUT /teams/{id}/repos/{org}/{repo} -> 204; persists unless repo-put-noop
|
||||
# GET /teams/{id}/members/{user} -> answers from persisted state only
|
||||
# GET /teams/{id}/repos/{org}/{repo} -> answers from persisted state only
|
||||
cat > "$BIN_DIR/curl" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Record the FULL argv exactly as spawned, before consumption. The bearer token
|
||||
# must NOT appear here — it is delivered via a curl --config file, so only the
|
||||
# config file PATH may show up.
|
||||
printf '%s\n' "$*" >> "$GRANT_REVIEWER_CURL_ARGV_LOG"
|
||||
|
||||
output_file=""
|
||||
method="GET"
|
||||
url=""
|
||||
data=""
|
||||
auth_token=""
|
||||
config_file=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-o) output_file="$2"; shift 2 ;;
|
||||
-H)
|
||||
[[ "$2" == Authorization:* ]] && auth_token="${2##* }"
|
||||
shift 2 ;;
|
||||
-K|--config) config_file="$2"; shift 2 ;;
|
||||
-w) shift 2 ;;
|
||||
-X) method="$2"; shift 2 ;;
|
||||
-d|--data) data="$2"; shift 2 ;;
|
||||
-s|-S|-sS) shift ;;
|
||||
http://*|https://*) url="$1"; shift ;;
|
||||
*) shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Resolve the bearer token from the curl --config file (its real, secure
|
||||
# source). The config line is `header = "Authorization: token <value>"`.
|
||||
if [[ -z "$auth_token" && -n "$config_file" && -f "$config_file" ]]; then
|
||||
config_hdr="$(grep -i 'Authorization' "$config_file" 2>/dev/null || true)"
|
||||
if [[ "$config_hdr" == *"token "* ]]; then
|
||||
auth_token="${config_hdr##*token }"
|
||||
auth_token="${auth_token%\"}"
|
||||
fi
|
||||
fi
|
||||
|
||||
path="${url%%\?*}"
|
||||
printf '%s %s\n' "$method" "$url" >> "$GRANT_REVIEWER_CURL_LOG"
|
||||
|
||||
# Map the presented bearer token to the identity it authenticates as. Every
|
||||
# request the wrapper makes must carry the SAME credential, so the identity
|
||||
# recorded here reveals which credential actually performed each request.
|
||||
acting_identity=""
|
||||
case "$auth_token" in
|
||||
"$GRANT_REVIEWER_DEFAULT_TOKEN") acting_identity="$GRANT_REVIEWER_DEFAULT_IDENTITY" ;;
|
||||
"$GRANT_REVIEWER_OVERRIDE_TOKEN") acting_identity="$GRANT_REVIEWER_OVERRIDE_LOGIN" ;;
|
||||
esac
|
||||
printf '%s %s %s\n' "$method" "$path" "${acting_identity:-<unauthenticated>}" >> "$GRANT_REVIEWER_AUTH_LOG"
|
||||
|
||||
write_response() {
|
||||
local status="$1" body="$2"
|
||||
[[ -n "$output_file" ]] || exit 96
|
||||
printf '%s' "$body" > "$output_file"
|
||||
printf '%s' "$status"
|
||||
}
|
||||
|
||||
[[ -n "$acting_identity" ]] || { write_response 401 '{"message":"unauthenticated"}'; exit 0; }
|
||||
|
||||
mode="$GRANT_REVIEWER_TEST_MODE"
|
||||
org="$GRANT_REVIEWER_ORG"
|
||||
api="$GRANT_REVIEWER_API_ROOT"
|
||||
|
||||
if [[ "$method" == "GET" && "$path" == "$api/orgs/$org" ]]; then
|
||||
if [[ "$mode" == "not-an-org" ]]; then
|
||||
write_response 404 '{"message":"not found"}'
|
||||
else
|
||||
write_response 200 "{\"username\":\"$org\"}"
|
||||
fi
|
||||
elif [[ "$method" == "GET" && "$path" == "$api/orgs/$org/teams/search" ]]; then
|
||||
result=$(python3 - "$GRANT_REVIEWER_STATE" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
|
||||
with open(sys.argv[1], encoding="utf-8") as handle:
|
||||
state = json.load(handle)
|
||||
print(json.dumps({"ok": True, "data": state["teams"]}))
|
||||
PY
|
||||
)
|
||||
write_response 200 "$result"
|
||||
elif [[ "$method" == "POST" && "$path" == "$api/orgs/$org/teams" ]]; then
|
||||
if [[ "$mode" == "create-403" ]]; then
|
||||
write_response 403 '{"message":"forbidden"}'
|
||||
exit 0
|
||||
fi
|
||||
result=$(GRANT_REVIEWER_DATA="$data" python3 - "$GRANT_REVIEWER_STATE" <<'PY'
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
payload = json.loads(os.environ["GRANT_REVIEWER_DATA"])
|
||||
expected = {
|
||||
"name": os.environ["GRANT_REVIEWER_TEAM_NAME"],
|
||||
"description": "review seats: code read + issues/pulls write",
|
||||
"permission": "read",
|
||||
"includes_all_repositories": False,
|
||||
"can_create_org_repo": False,
|
||||
"units_map": {
|
||||
"repo.code": "read",
|
||||
"repo.issues": "write",
|
||||
"repo.pulls": "write",
|
||||
},
|
||||
}
|
||||
if payload != expected:
|
||||
with open(os.environ["GRANT_REVIEWER_PAYLOAD_VIOLATION"], "w", encoding="utf-8") as handle:
|
||||
json.dump({"got": payload, "expected": expected}, handle, indent=2)
|
||||
print("422")
|
||||
print(json.dumps({"message": "payload mismatch"}))
|
||||
raise SystemExit(0)
|
||||
|
||||
state_path = sys.argv[1]
|
||||
with open(state_path, encoding="utf-8") as handle:
|
||||
state = json.load(handle)
|
||||
team = {"id": int(os.environ["GRANT_REVIEWER_TEAM_ID"]), "name": payload["name"]}
|
||||
state["teams"].append(team)
|
||||
with open(state_path, "w", encoding="utf-8") as handle:
|
||||
json.dump(state, handle)
|
||||
print("201")
|
||||
print(json.dumps(team))
|
||||
PY
|
||||
)
|
||||
response_status="${result%%$'\n'*}"
|
||||
response_body="${result#*$'\n'}"
|
||||
write_response "$response_status" "$response_body"
|
||||
elif [[ "$method" == "PUT" && "$path" == "$api/teams/$GRANT_REVIEWER_TEAM_ID/members/$GRANT_REVIEWER_REVIEWER" ]]; then
|
||||
# Sabotage mode member-put-noop: 204 WITHOUT persisting — the exit-code lie.
|
||||
if [[ "$mode" != "member-put-noop" ]]; then
|
||||
python3 - "$GRANT_REVIEWER_STATE" <<'PY'
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
state_path = sys.argv[1]
|
||||
with open(state_path, encoding="utf-8") as handle:
|
||||
state = json.load(handle)
|
||||
member = os.environ["GRANT_REVIEWER_REVIEWER"]
|
||||
if member not in state["members"]:
|
||||
state["members"].append(member)
|
||||
with open(state_path, "w", encoding="utf-8") as handle:
|
||||
json.dump(state, handle)
|
||||
PY
|
||||
fi
|
||||
write_response 204 ''
|
||||
elif [[ "$method" == "PUT" && "$path" == "$api/teams/$GRANT_REVIEWER_TEAM_ID/repos/$GRANT_REVIEWER_REPO_SLUG" ]]; then
|
||||
# Sabotage mode repo-put-noop: 204 WITHOUT persisting.
|
||||
if [[ "$mode" != "repo-put-noop" ]]; then
|
||||
python3 - "$GRANT_REVIEWER_STATE" <<'PY'
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
state_path = sys.argv[1]
|
||||
with open(state_path, encoding="utf-8") as handle:
|
||||
state = json.load(handle)
|
||||
slug = os.environ["GRANT_REVIEWER_REPO_SLUG"]
|
||||
if slug not in state["repos"]:
|
||||
state["repos"].append(slug)
|
||||
with open(state_path, "w", encoding="utf-8") as handle:
|
||||
json.dump(state, handle)
|
||||
PY
|
||||
fi
|
||||
write_response 204 ''
|
||||
elif [[ "$method" == "GET" && "$path" == "$api/teams/$GRANT_REVIEWER_TEAM_ID/members/$GRANT_REVIEWER_REVIEWER" ]]; then
|
||||
if python3 - "$GRANT_REVIEWER_STATE" <<'PY'
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
with open(sys.argv[1], encoding="utf-8") as handle:
|
||||
state = json.load(handle)
|
||||
raise SystemExit(0 if os.environ["GRANT_REVIEWER_REVIEWER"] in state["members"] else 1)
|
||||
PY
|
||||
then
|
||||
write_response 200 "{\"login\":\"$GRANT_REVIEWER_REVIEWER\"}"
|
||||
else
|
||||
write_response 404 '{"message":"not a member"}'
|
||||
fi
|
||||
elif [[ "$method" == "GET" && "$path" == "$api/teams/$GRANT_REVIEWER_TEAM_ID/repos/$GRANT_REVIEWER_REPO_SLUG" ]]; then
|
||||
if python3 - "$GRANT_REVIEWER_STATE" <<'PY'
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
with open(sys.argv[1], encoding="utf-8") as handle:
|
||||
state = json.load(handle)
|
||||
raise SystemExit(0 if os.environ["GRANT_REVIEWER_REPO_SLUG"] in state["repos"] else 1)
|
||||
PY
|
||||
then
|
||||
write_response 200 "{\"full_name\":\"$GRANT_REVIEWER_REPO_SLUG\"}"
|
||||
else
|
||||
write_response 404 '{"message":"repo not on team"}'
|
||||
fi
|
||||
else
|
||||
echo "Unexpected curl request: $method $url" >&2
|
||||
exit 97
|
||||
fi
|
||||
SH
|
||||
chmod +x "$BIN_DIR/curl"
|
||||
|
||||
# Seed persistent server state for a mode: fresh (no team yet) or a pre-seeded
|
||||
# team. The DECOY team — whose name CONTAINS the wanted name — is always listed
|
||||
# FIRST, so a first-result or substring match would grab the wrong team.
|
||||
seed_state() {
|
||||
local seeded_team="$1"
|
||||
GRANT_REVIEWER_SEEDED_TEAM="$seeded_team" GRANT_REVIEWER_TEAM_NAME="$TEAM_NAME" \
|
||||
GRANT_REVIEWER_TEAM_ID="$TEAM_ID" GRANT_REVIEWER_DECOY_TEAM_ID="$DECOY_TEAM_ID" \
|
||||
python3 - "$STATE_FILE" <<'PY'
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
wanted = os.environ["GRANT_REVIEWER_TEAM_NAME"]
|
||||
teams = [{"id": int(os.environ["GRANT_REVIEWER_DECOY_TEAM_ID"]), "name": wanted + "-archive"}]
|
||||
if os.environ["GRANT_REVIEWER_SEEDED_TEAM"] == "yes":
|
||||
teams.append({"id": int(os.environ["GRANT_REVIEWER_TEAM_ID"]), "name": wanted})
|
||||
with open(sys.argv[1], "w", encoding="utf-8") as handle:
|
||||
json.dump({"teams": teams, "members": [], "repos": []}, handle)
|
||||
PY
|
||||
}
|
||||
|
||||
# run_grant <mode> <seeded-team yes|no> [extra env VAR=value ...] -- [wrapper args ...]
|
||||
run_grant() {
|
||||
local mode="$1" seeded="$2"
|
||||
shift 2
|
||||
local -a extra_env=()
|
||||
while [[ $# -gt 0 && "$1" != "--" ]]; do
|
||||
extra_env+=("$1")
|
||||
shift
|
||||
done
|
||||
[[ $# -gt 0 ]] && shift
|
||||
: > "$TEA_LOG"
|
||||
: > "$CURL_LOG"
|
||||
: > "$CURL_ARGV_LOG"
|
||||
: > "$AUTH_LOG"
|
||||
: > "$OUTPUT_FILE"
|
||||
rm -f "$PAYLOAD_VIOLATION_FILE"
|
||||
seed_state "$seeded"
|
||||
(
|
||||
cd "$RUN_REPO_DIR"
|
||||
env \
|
||||
PATH="$BIN_DIR:$PATH" \
|
||||
TMPDIR="$TMP_SCRATCH" \
|
||||
HOME="$HOME_DIR" \
|
||||
XDG_CONFIG_HOME="$XDG_DIR" \
|
||||
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
||||
MOSAIC_BRAIN_HOME="$HOME_DIR/.mosaic" \
|
||||
MOSAIC_GIT_IDENTITY= \
|
||||
GITEA_LOGIN= \
|
||||
GITEA_TOKEN= \
|
||||
GITEA_URL= \
|
||||
GRANT_REVIEWER_TEA_LOG="$TEA_LOG" \
|
||||
GRANT_REVIEWER_CURL_LOG="$CURL_LOG" \
|
||||
GRANT_REVIEWER_CURL_ARGV_LOG="$CURL_ARGV_LOG" \
|
||||
GRANT_REVIEWER_AUTH_LOG="$AUTH_LOG" \
|
||||
GRANT_REVIEWER_STATE="$STATE_FILE" \
|
||||
GRANT_REVIEWER_TEST_MODE="$mode" \
|
||||
GRANT_REVIEWER_ORG="$ORG" \
|
||||
GRANT_REVIEWER_API_ROOT="$API_ROOT" \
|
||||
GRANT_REVIEWER_TEAM_NAME="$TEAM_NAME" \
|
||||
GRANT_REVIEWER_TEAM_ID="$TEAM_ID" \
|
||||
GRANT_REVIEWER_REVIEWER="$REVIEWER" \
|
||||
GRANT_REVIEWER_REPO_SLUG="$REPO_SLUG" \
|
||||
GRANT_REVIEWER_DEFAULT_TOKEN="$DEFAULT_TOKEN" \
|
||||
GRANT_REVIEWER_DEFAULT_IDENTITY="$DEFAULT_IDENTITY" \
|
||||
GRANT_REVIEWER_OVERRIDE_LOGIN="$OVERRIDE_LOGIN" \
|
||||
GRANT_REVIEWER_OVERRIDE_TOKEN="$OVERRIDE_TOKEN" \
|
||||
GRANT_REVIEWER_PAYLOAD_VIOLATION="$PAYLOAD_VIOLATION_FILE" \
|
||||
"${extra_env[@]}" \
|
||||
"$SCRIPT_DIR/grant-reviewer.sh" -u "$REVIEWER" "$@"
|
||||
) > "$OUTPUT_FILE" 2>&1
|
||||
}
|
||||
|
||||
assert_no_temp_leak() {
|
||||
local context="$1" leaked
|
||||
# Includes the curl auth-config files (mosaic-gitea-auth-*), which carry the
|
||||
# bearer token and must be unlinked on every exit path.
|
||||
leaked=$(find "$TMP_SCRATCH" -type f \( -name 'mosaic-grant-reviewer-*' -o -name 'mosaic-gitea-auth-*' \) 2>/dev/null || true)
|
||||
if [[ -n "$leaked" ]]; then
|
||||
echo "FAIL: grant-reviewer temp files leaked ($context):" >&2
|
||||
printf '%s\n' "$leaked" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
assert_token_not_in_argv() {
|
||||
local context="$1"
|
||||
if grep -qF -e "$DEFAULT_TOKEN" -e "$OVERRIDE_TOKEN" "$CURL_ARGV_LOG"; then
|
||||
echo "FAIL: a Gitea bearer token leaked into curl argv ($context)" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -q -- '--config' "$CURL_ARGV_LOG"; then
|
||||
echo "FAIL: curl was not invoked with --config file auth ($context)" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
assert_no_payload_violation() {
|
||||
local context="$1"
|
||||
if [[ -f "$PAYLOAD_VIOLATION_FILE" ]]; then
|
||||
echo "FAIL: team create payload deviated from the reviewer contract ($context):" >&2
|
||||
cat "$PAYLOAD_VIOLATION_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
RUN_REPO_DIR="$REPO_DIR"
|
||||
|
||||
# Case 1: fresh grant — team absent, created with the exact reviewer payload,
|
||||
# member + repo PUTs persist, both read-backs verify against server state.
|
||||
run_grant normal no -- || {
|
||||
echo "FAIL: fresh grant exited nonzero" >&2
|
||||
cat "$OUTPUT_FILE" >&2
|
||||
exit 1
|
||||
}
|
||||
grep -q "Created team '$TEAM_NAME' (id $TEAM_ID) on org '$ORG'" "$OUTPUT_FILE" || {
|
||||
echo "FAIL: fresh grant did not create the team" >&2
|
||||
cat "$OUTPUT_FILE" >&2
|
||||
exit 1
|
||||
}
|
||||
grep -q "Granted: '$REVIEWER' is a member of team '$TEAM_NAME' (id $TEAM_ID) with access to '$REPO_SLUG'" "$OUTPUT_FILE" || {
|
||||
echo "FAIL: fresh grant did not report a verified grant" >&2
|
||||
cat "$OUTPUT_FILE" >&2
|
||||
exit 1
|
||||
}
|
||||
assert_no_payload_violation "fresh"
|
||||
assert_token_not_in_argv "fresh"
|
||||
assert_no_temp_leak "fresh"
|
||||
# The default path must have acted as the host-default identity on EVERY request.
|
||||
if grep -qv " $DEFAULT_IDENTITY\$" "$AUTH_LOG"; then
|
||||
echo "FAIL: fresh grant made a request under an unexpected identity" >&2
|
||||
cat "$AUTH_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
# grant-reviewer must never shell out to tea.
|
||||
if [[ -s "$TEA_LOG" ]]; then
|
||||
echo "FAIL: grant-reviewer invoked tea" >&2
|
||||
cat "$TEA_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Case 2: idempotent — the team already exists. It must be found by EXACT name
|
||||
# (the decoy is listed first), no create POST issued, and the decoy team must
|
||||
# never be touched.
|
||||
run_grant normal yes -- || {
|
||||
echo "FAIL: idempotent grant exited nonzero" >&2
|
||||
cat "$OUTPUT_FILE" >&2
|
||||
exit 1
|
||||
}
|
||||
grep -q "Found existing team '$TEAM_NAME' (id $TEAM_ID) on org '$ORG'" "$OUTPUT_FILE" || {
|
||||
echo "FAIL: idempotent grant did not find the existing team" >&2
|
||||
cat "$OUTPUT_FILE" >&2
|
||||
exit 1
|
||||
}
|
||||
grep -q "Granted: '$REVIEWER'" "$OUTPUT_FILE" || {
|
||||
echo "FAIL: idempotent grant did not report a verified grant" >&2
|
||||
cat "$OUTPUT_FILE" >&2
|
||||
exit 1
|
||||
}
|
||||
if grep -q "^POST " "$CURL_LOG"; then
|
||||
echo "FAIL: idempotent grant issued a create POST for an existing team" >&2
|
||||
cat "$CURL_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
if grep -q "/teams/$DECOY_TEAM_ID/" "$CURL_LOG"; then
|
||||
echo "FAIL: substring-named decoy team was operated on" >&2
|
||||
cat "$CURL_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
assert_no_temp_leak "idempotent"
|
||||
|
||||
# Case 3: GITEA_LOGIN override — every request must carry the override login's
|
||||
# token, never the host default credential.
|
||||
run_grant normal no GITEA_LOGIN="$OVERRIDE_LOGIN" -- || {
|
||||
echo "FAIL: GITEA_LOGIN override grant exited nonzero" >&2
|
||||
cat "$OUTPUT_FILE" >&2
|
||||
exit 1
|
||||
}
|
||||
grep -q "Granted: '$REVIEWER'" "$OUTPUT_FILE" || {
|
||||
echo "FAIL: GITEA_LOGIN override grant did not succeed" >&2
|
||||
cat "$OUTPUT_FILE" >&2
|
||||
exit 1
|
||||
}
|
||||
if grep -qv " $OVERRIDE_LOGIN\$" "$AUTH_LOG"; then
|
||||
echo "FAIL: GITEA_LOGIN override made a request under a different identity" >&2
|
||||
cat "$AUTH_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
assert_token_not_in_argv "override"
|
||||
assert_no_temp_leak "override"
|
||||
|
||||
# Case 4: unresolvable GITEA_LOGIN — fail closed BEFORE any API call; no
|
||||
# downgrade to the host default identity.
|
||||
if run_grant normal no GITEA_LOGIN="no-such-login" --; then
|
||||
echo "FAIL: unresolvable GITEA_LOGIN did not fail" >&2
|
||||
cat "$OUTPUT_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -q "refusing to fall back to the host default identity" "$OUTPUT_FILE" || {
|
||||
echo "FAIL: unresolvable GITEA_LOGIN missing the fail-closed message" >&2
|
||||
cat "$OUTPUT_FILE" >&2
|
||||
exit 1
|
||||
}
|
||||
if [[ -s "$CURL_LOG" ]]; then
|
||||
echo "FAIL: unresolvable GITEA_LOGIN still made API calls" >&2
|
||||
cat "$CURL_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
assert_no_temp_leak "unresolvable-login"
|
||||
|
||||
# Case 5: GitHub-remoted repo — refuse before any API call.
|
||||
RUN_REPO_DIR="$GH_REPO_DIR"
|
||||
if run_grant normal no --; then
|
||||
echo "FAIL: GitHub repo was not refused" >&2
|
||||
cat "$OUTPUT_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -q "Gitea only" "$OUTPUT_FILE" || {
|
||||
echo "FAIL: GitHub refusal missing the 'Gitea only' message" >&2
|
||||
cat "$OUTPUT_FILE" >&2
|
||||
exit 1
|
||||
}
|
||||
if [[ -s "$CURL_LOG" ]]; then
|
||||
echo "FAIL: GitHub refusal still made API calls" >&2
|
||||
cat "$CURL_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
RUN_REPO_DIR="$REPO_DIR"
|
||||
|
||||
# Case 6: owner is not an organization — clear refusal.
|
||||
if run_grant not-an-org no --; then
|
||||
echo "FAIL: non-org owner was not refused" >&2
|
||||
cat "$OUTPUT_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -q "is not an organization" "$OUTPUT_FILE" || {
|
||||
echo "FAIL: non-org refusal missing its message" >&2
|
||||
cat "$OUTPUT_FILE" >&2
|
||||
exit 1
|
||||
}
|
||||
assert_no_temp_leak "not-an-org"
|
||||
|
||||
# Case 7: HTTP 403 on team create — reported as an org-admin requirement, and
|
||||
# the run stops before any member/repo PUT (no partial grant).
|
||||
if run_grant create-403 no --; then
|
||||
echo "FAIL: 403 on team create did not fail the run" >&2
|
||||
cat "$OUTPUT_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -q "org admin required on '$ORG'" "$OUTPUT_FILE" || {
|
||||
echo "FAIL: 403 was not mapped to the org-admin message" >&2
|
||||
cat "$OUTPUT_FILE" >&2
|
||||
exit 1
|
||||
}
|
||||
if grep -q "^PUT " "$CURL_LOG"; then
|
||||
echo "FAIL: run continued into PUTs after a 403 (partial grant)" >&2
|
||||
cat "$CURL_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
assert_no_temp_leak "create-403"
|
||||
|
||||
# Cases 8-9: the exit-code lie — a PUT answers 204 without persisting. The
|
||||
# read-back must fail closed; no success line may appear.
|
||||
for noop_mode in member-put-noop repo-put-noop; do
|
||||
if run_grant "$noop_mode" no --; then
|
||||
echo "FAIL: $noop_mode was reported as success" >&2
|
||||
cat "$OUTPUT_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -q "NOT verified" "$OUTPUT_FILE" || {
|
||||
echo "FAIL: $noop_mode missing the fail-closed verification message" >&2
|
||||
cat "$OUTPUT_FILE" >&2
|
||||
exit 1
|
||||
}
|
||||
if grep -q "^Granted:" "$OUTPUT_FILE"; then
|
||||
echo "FAIL: $noop_mode still printed the success line" >&2
|
||||
exit 1
|
||||
fi
|
||||
assert_no_temp_leak "$noop_mode"
|
||||
done
|
||||
|
||||
echo "grant-reviewer.sh org-team grant + fail-closed read-back regression passed"
|
||||
@@ -0,0 +1,333 @@
|
||||
#!/usr/bin/env bash
|
||||
# test-repo-decl-consumption.sh — hermetic declaration-consumption suite for the
|
||||
# git wrappers (T51 WP5b).
|
||||
#
|
||||
# Spec of record (brain repo): docs/plans/2026-08-23_repo-structure-declaration.md
|
||||
# sections 4 (consumption), 5.3 (normalization), 5.4 (hostile-input classes),
|
||||
# 1.2a (root anchoring). Covers every §5.4 class applicable to consumed fields
|
||||
# plus the per-tool behaviors (base precedence, transition validation, remote
|
||||
# fail-closed, absence policy, route context, staged worktree rule).
|
||||
#
|
||||
# Red-first usage: WP5B_TOOLS=<dir with PRE-change tool copies> bash $0
|
||||
# exits nonzero — the declaration-driven arms fail against tools that predate
|
||||
# the change (evidence captured in the WP5b report).
|
||||
#
|
||||
# Hermetic: scratch repos under $TMPDIR, PATH-stubbed curl, sandboxed HOME; no
|
||||
# network, no live forge, no writes outside the sandbox.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
TOOLS_SRC="${WP5B_TOOLS:-$SCRIPT_DIR}"
|
||||
TROOT="${TMPDIR:-/tmp}"
|
||||
|
||||
PASS=0 FAIL=0 FAILED_CASES=""
|
||||
FIXTURES=()
|
||||
cleanup_all() { local f; for f in "${FIXTURES[@]:-}"; do rm -rf -- "$f"; done; }
|
||||
trap cleanup_all EXIT INT TERM
|
||||
|
||||
ok() { PASS=$((PASS + 1)); }
|
||||
bad() { FAIL=$((FAIL + 1)); FAILED_CASES="$FAILED_CASES $1"; printf 'FAIL: %s\n' "$1" >&2; }
|
||||
assert_rc() { local d="$1" e="$2" a="$3"; [ "$e" = "$a" ] && ok || bad "$d (expected rc=$e got rc=$a)"; }
|
||||
assert_eq() { local d="$1" e="$2" a="$3"; [ "$e" = "$a" ] && ok || bad "$d (expected [$e] got [$a])"; }
|
||||
assert_contains() { local d="$1" h="$2" n="$3"; case "$h" in *"$n"*) ok ;; *) bad "$d (missing [$n])" ;; esac; }
|
||||
assert_not_contains() { local d="$1" h="$2" n="$3"; case "$h" in *"$n"*) bad "$d (unexpected [$n])" ;; *) ok ;; esac; }
|
||||
assert_count() { local d="$1" e="$2" a="$3"; [ "$e" = "$a" ] && ok || bad "$d (expected $e got $a)"; }
|
||||
|
||||
new_sb() { SB="$(mktemp -d "$TROOT/wp5b-test.XXXXXX")"; FIXTURES+=("$SB"); }
|
||||
|
||||
# A fixture repo with the (possibly pre-change) tools + validator installed.
|
||||
# $1 dir, stdin = declaration JSON ("" = none), $2 = origin url ("" = none)
|
||||
mkrepo() {
|
||||
local d="$1" decl_origin="${2:-}"
|
||||
mkdir -p "$d/tools/git" "$d/tools/structure" "$d/home"
|
||||
cp "$TOOLS_SRC/repo-decl.sh" "$d/tools/git/" 2>/dev/null || true
|
||||
cp "$TOOLS_SRC/pr-create.sh" "$TOOLS_SRC/pr-merge.sh" "$TOOLS_SRC/mosaic-worktree.sh" "$TOOLS_SRC/ci-queue-wait.sh" "$TOOLS_SRC/detect-platform.sh" "$d/tools/git/" 2>/dev/null || true
|
||||
cp "$SCRIPT_DIR/../structure/validate-repo-json.sh" "$d/tools/structure/"
|
||||
git -C "$d" init -q -b feature/x
|
||||
git -C "$d" config user.name fixture; git -C "$d" config user.email fixture@test
|
||||
mkdir -p "$d/.mosaic" "$d/sites"
|
||||
printf 'base\n' > "$d/f"; git -C "$d" add -A; git -C "$d" commit -q -m base
|
||||
[ -n "$decl_origin" ] && git -C "$d" remote add origin "$decl_origin"
|
||||
cat > "$d/.mosaic/repo.json"
|
||||
# whitespace-only input (the absent-decl arms use <<< "") must mean ABSENT,
|
||||
# not an invalid-file fixture: strip it to no file.
|
||||
grep -q '[^[:space:]]' "$d/.mosaic/repo.json" 2>/dev/null || rm -f "$d/.mosaic/repo.json"
|
||||
}
|
||||
|
||||
# The canonical v2 declaration used by default (custom branch names prove the
|
||||
# tools never hardcode): trunk=dev-trunk release=prod-rel flow=trunk-release.
|
||||
DECL_TR='{
|
||||
"schema_version": 2,
|
||||
"integration_trunk": "dev-trunk",
|
||||
"release_branch": "prod-rel",
|
||||
"flow": "trunk-release",
|
||||
"canonical_remote": "https://git.example.test/acme/widgets",
|
||||
"canonical_clone": "host:/src/widgets",
|
||||
"worktree_root": "host:/src/widgets-worktrees",
|
||||
"worktree_policy": "orchestrator-precreated"
|
||||
}'
|
||||
|
||||
decl_direct() { printf '{\n "schema_version": 2,\n "integration_trunk": "mainline",\n "release_branch": "mainline",\n "flow": "direct",\n "canonical_remote": "https://git.example.test/acme/widgets",\n "canonical_clone": "host:/src/widgets"\n}\n'; }
|
||||
|
||||
load_decl_in() { # $1 dir -> runs repo_decl_load in a subshell, prints STATE etc.
|
||||
( cd "$1" && source "$1/tools/git/repo-decl.sh" && repo_decl_load \
|
||||
&& printf 'STATE=%s SCHEMA=%s TRUNK=%s RELEASE=%s FLOW=%s ERROR=%s\n' \
|
||||
"$DECL_STATE" "${DECL_SCHEMA:-}" "${DECL_TRUNK:-}" "${DECL_RELEASE:-}" "${DECL_FLOW:-}" "${DECL_ERROR:-}" )
|
||||
}
|
||||
|
||||
echo "== A. lib classification + hostile inputs (spec 5.4 classes) =="
|
||||
new_sb; mkrepo "$SB/r1" <<< "$DECL_TR" "https://git.example.test/acme/widgets.git"
|
||||
A="$(load_decl_in "$SB/r1")"
|
||||
assert_contains "A1 valid v2 state" "$A" "STATE=valid"
|
||||
assert_contains "A1 trunk" "$A" "TRUNK=dev-trunk"
|
||||
assert_contains "A1 release" "$A" "RELEASE=prod-rel"
|
||||
assert_contains "A1 flow" "$A" "FLOW=trunk-release"
|
||||
assert_contains "A1 schema" "$A" "SCHEMA=2"
|
||||
|
||||
new_sb; mkrepo "$SB/r2" <<< "" "https://git.example.test/acme/widgets"
|
||||
A="$(load_decl_in "$SB/r2")"
|
||||
assert_contains "A2 missing file = absent" "$A" "STATE=absent"
|
||||
|
||||
new_sb; mkrepo "$SB/r3" <<< '{ not json'
|
||||
A="$(load_decl_in "$SB/r3")"
|
||||
assert_contains "A3 malformed JSON = invalid" "$A" "STATE=invalid"
|
||||
assert_contains "A3 error names the failure" "$A" "VALIDATION_ERROR"
|
||||
|
||||
new_sb; mkrepo "$SB/r4" <<< '{"schema_version": 99, "integration_trunk": "x", "release_branch": "y", "flow": "direct", "canonical_remote": "https://a/b"}'
|
||||
A="$(load_decl_in "$SB/r4")"
|
||||
assert_contains "A4 unknown schema_version = invalid" "$A" "STATE=invalid"
|
||||
|
||||
new_sb; mkrepo "$SB/r5" <<< '{"integration_trunk": "x", "release_branch": "y"}'
|
||||
A="$(load_decl_in "$SB/r5")"
|
||||
assert_contains "A5 v1 validates" "$A" "STATE=valid"
|
||||
assert_contains "A5 v1 schema recorded" "$A" "SCHEMA=1"
|
||||
|
||||
new_sb; mkrepo "$SB/r6" <<< '{"schema_version": 2, "integration_trunk": "x", "release_branch": "y", "flow": "direct", "canonical_remote": "https://a/b", "surprise": 1}'
|
||||
A="$(load_decl_in "$SB/r6")"
|
||||
assert_contains "A6 unknown top-level key = invalid" "$A" "STATE=invalid"
|
||||
|
||||
new_sb; mkrepo "$SB/r7" <<< '{"schema_version": 2, "integration_trunk": "bad..name", "release_branch": "y", "flow": "direct", "canonical_remote": "https://a/b"}'
|
||||
A="$(load_decl_in "$SB/r7")"
|
||||
assert_contains "A7 bad ref name = invalid" "$A" "STATE=invalid"
|
||||
|
||||
new_sb; mkrepo "$SB/r8" <<< '{"schema_version": 2, "integration_trunk": "a", "release_branch": "b", "flow": "direct", "canonical_remote": "https://a/b"}'
|
||||
A="$(load_decl_in "$SB/r8")"
|
||||
assert_contains "A8 cross-field violation = invalid" "$A" "STATE=invalid"
|
||||
|
||||
new_sb; mkrepo "$SB/r9" <<< '{"schema_version": 2, "integration_trunk": "a", "release_branch": "b", "flow": "trunk-release", "canonical_remote": "https://user:pw@a/b"}'
|
||||
A="$(load_decl_in "$SB/r9")"
|
||||
assert_contains "A9 userinfo URL = invalid" "$A" "STATE=invalid"
|
||||
|
||||
echo "== A2. transitions + remote + path anchoring =="
|
||||
new_sb; mkrepo "$SB/t1" <<< "$DECL_TR"
|
||||
T=(); rc=0
|
||||
T_out="$( cd "$SB/t1" && source tools/git/repo-decl.sh && repo_decl_load
|
||||
repo_decl_check_transition feat dev-trunk && echo "feat->trunk:ALLOWED"
|
||||
repo_decl_check_transition dev-trunk prod-rel && echo "trunk->rel:ALLOWED"
|
||||
repo_decl_check_transition feat prod-rel || echo "feat->rel:REFUSED"
|
||||
repo_decl_check_transition other other || echo "same:REFUSED"
|
||||
repo_decl_check_transition feat elsewhere || echo "arbitrary:REFUSED" )"
|
||||
assert_contains "T1 feature->trunk allowed" "$T_out" "feat->trunk:ALLOWED"
|
||||
assert_contains "T1 trunk->release allowed" "$T_out" "trunk->rel:ALLOWED"
|
||||
assert_contains "T1 feature->release refused" "$T_out" "feat->rel:REFUSED"
|
||||
assert_contains "T1 head==base refused" "$T_out" "same:REFUSED"
|
||||
assert_contains "T1 arbitrary target refused" "$T_out" "arbitrary:REFUSED"
|
||||
|
||||
new_sb; mkrepo "$SB/t2" <<< "$(decl_direct)"
|
||||
T_out="$( cd "$SB/t2" && source tools/git/repo-decl.sh && repo_decl_load
|
||||
repo_decl_check_transition feat mainline && echo "direct-ok:ALLOWED"
|
||||
repo_decl_check_transition feat other || echo "direct-other:REFUSED" )"
|
||||
assert_contains "T2 direct feature->trunk allowed" "$T_out" "direct-ok:ALLOWED"
|
||||
assert_contains "T2 direct other base refused" "$T_out" "direct-other:REFUSED"
|
||||
|
||||
new_sb; mkrepo "$SB/t3" <<< "$DECL_TR" "https://Git.Example.Test/acme/widgets.git/"
|
||||
M="$( cd "$SB/t3" && source tools/git/repo-decl.sh && repo_decl_load && repo_decl_remote_matches && echo MATCH )"
|
||||
assert_contains "T3 normalization: .git/case differences still MATCH" "$M" "MATCH"
|
||||
new_sb; mkrepo "$SB/t4" <<< "$DECL_TR" "https://git.example.test/acme/OTHER"
|
||||
M="$( cd "$SB/t4" && source tools/git/repo-decl.sh && repo_decl_load && { repo_decl_remote_matches && echo MATCH; } || echo MISMATCH )"
|
||||
assert_contains "T4 remote mismatch detected" "$M" "MISMATCH"
|
||||
|
||||
new_sb; mkrepo "$SB/t5" <<< "$DECL_TR"
|
||||
P="$( cd "$SB/t5" && source tools/git/repo-decl.sh && { repo_decl_path "host:/src/x" 2>/dev/null && echo RESOLVED; } || echo FAILCLOSED )"
|
||||
assert_contains "T5 host:/ resolution fails closed (root unset)" "$P" "FAILCLOSED"
|
||||
|
||||
echo "== B. pr-create consumption =="
|
||||
mkpr() { # $1 dir: install the curl stub + run env; sets PR_RC/PR_OUT/PR_ERR/PR_PAYLOAD
|
||||
mkdir -p "$1/stub"
|
||||
cat > "$1/stub/curl" <<'STUB'
|
||||
#!/usr/bin/env bash
|
||||
url="${*: -1}"
|
||||
printf 'curl %s\n' "$*" >> "${STUB_DIR:?}/calls.log"
|
||||
case "$url" in
|
||||
*/api/v1/repos/acme/widgets) printf '%s\n' '{"default_branch":"forge-default"}'; exit 0 ;;
|
||||
*/pulls)
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in -d) printf '%s' "$2" > "${STUB_DIR:?}/payload.json"; shift 2 ;; *) shift ;; esac
|
||||
done
|
||||
printf '%s\n' '{"number":42}'; exit 0 ;;
|
||||
*) printf '%s\n' '{}'; exit 0 ;;
|
||||
esac
|
||||
STUB
|
||||
chmod +x "$1/stub/curl"
|
||||
}
|
||||
run_pr() { # $1 dir, rest args -> pr-create
|
||||
local prdir="$1"; shift
|
||||
PR_RC=0
|
||||
PR_OUT="$(cd "$prdir" && env -i PATH="$prdir/stub:/usr/bin:/bin" HOME="$prdir/home" \
|
||||
GITEA_TOKEN=stub-token STUB_DIR="$prdir" \
|
||||
bash "$prdir/tools/git/pr-create.sh" "$@" < /dev/null 2>"$prdir/err.txt")" || PR_RC=$?
|
||||
PR_ERR="$(cat "$prdir/err.txt")"
|
||||
PR_PAYLOAD="$(cat "$prdir/payload.json" 2>/dev/null || true)"
|
||||
PR_GETS="$(grep -c 'repos/acme/widgets$' "$prdir/calls.log" 2>/dev/null || true)"; PR_GETS="${PR_GETS:-0}"
|
||||
PR_POSTS="$(grep -c '/pulls$' "$prdir/calls.log" 2>/dev/null || true)"; PR_POSTS="${PR_POSTS:-0}"
|
||||
: > "$prdir/calls.log" 2>/dev/null || true
|
||||
rm -f "$prdir/payload.json"
|
||||
}
|
||||
|
||||
new_sb; mkrepo "$SB/b1" <<< "$DECL_TR" "https://git.example.test/acme/widgets"; mkpr "$SB/b1"
|
||||
git -C "$SB/b1" checkout -q -b feature/x 2>/dev/null || true
|
||||
run_pr "$SB/b1" -t "T"
|
||||
assert_rc "B1 declared trunk base rc 0" 0 "$PR_RC"
|
||||
base="$(printf '%s' "$PR_PAYLOAD" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("base",""))' 2>/dev/null || true)"
|
||||
assert_eq "B1 payload base = declared trunk (no -B)" "dev-trunk" "$base"
|
||||
assert_count "B1 zero repo GETs (declared trunk consulted, not the forge default)" 0 "$PR_GETS"
|
||||
|
||||
run_pr "$SB/b1" -t "T" -B prod-rel
|
||||
assert_rc "B2 -B feature->release REFUSED (4.2)" 1 "$PR_RC"
|
||||
assert_contains "B2 names the transition rule" "$PR_ERR" "not an allowed transition"
|
||||
assert_count "B2 no POST issued" 0 "$PR_POSTS"
|
||||
|
||||
run_pr "$SB/b1" -t "T" -B dev-trunk
|
||||
assert_rc "B3 -B feature->trunk allowed" 0 "$PR_RC"
|
||||
base="$(printf '%s' "$PR_PAYLOAD" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("base",""))' 2>/dev/null || true)"
|
||||
assert_eq "B3 payload base = explicit allowed -B" "dev-trunk" "$base"
|
||||
|
||||
run_pr "$SB/b1" -t "T" -B dev-trunk --head dev-trunk
|
||||
assert_rc "B4 -B trunk->trunk (head==base) refused" 1 "$PR_RC"
|
||||
|
||||
new_sb; mkrepo "$SB/b5" <<< "$DECL_TR" "https://git.example.test/acme/wrong"; mkpr "$SB/b5"
|
||||
run_pr "$SB/b5" -t "T"
|
||||
assert_rc "B5 remote mismatch on write path refuses" 1 "$PR_RC"
|
||||
assert_contains "B5 names the 5.3 rule" "$PR_ERR" "canonical_remote"
|
||||
assert_count "B5 no POST" 0 "$PR_POSTS"
|
||||
|
||||
new_sb; mkrepo "$SB/b6" <<< "" "https://git.example.test/acme/widgets"; mkpr "$SB/b6"
|
||||
run_pr "$SB/b6" -t "T"
|
||||
assert_rc "B6 absent decl: legacy forge default rc 0" 0 "$PR_RC"
|
||||
base="$(printf '%s' "$PR_PAYLOAD" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("base",""))' 2>/dev/null || true)"
|
||||
assert_eq "B6 payload base = forge default" "forge-default" "$base"
|
||||
assert_count "B6 repo GET performed (WP5a floor preserved)" 1 "$PR_GETS"
|
||||
assert_contains "B6 absence warning present" "$PR_ERR" "unmanaged during rollout"
|
||||
|
||||
new_sb; mkrepo "$SB/b7" <<< '{ not json' "https://git.example.test/acme/widgets"; mkpr "$SB/b7"
|
||||
run_pr "$SB/b7" -t "T"
|
||||
assert_rc "B7 invalid decl: loud report + legacy proceed" 0 "$PR_RC"
|
||||
assert_contains "B7 validation error reported" "$PR_ERR" "declaration INVALID"
|
||||
base="$(printf '%s' "$PR_PAYLOAD" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("base",""))' 2>/dev/null || true)"
|
||||
assert_eq "B7 legacy forge default used" "forge-default" "$base"
|
||||
|
||||
new_sb; mkrepo "$SB/b8" <<< '{"integration_trunk": "x", "release_branch": "y"}' "https://git.example.test/acme/widgets"; mkpr "$SB/b8"
|
||||
run_pr "$SB/b8" -t "T"
|
||||
assert_rc "B8 v1 decl: legacy proceed rc 0" 0 "$PR_RC"
|
||||
assert_contains "B8 v1 note present" "$PR_ERR" "no consumable flow/trunk fields"
|
||||
base="$(printf '%s' "$PR_PAYLOAD" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("base",""))' 2>/dev/null || true)"
|
||||
assert_eq "B8 legacy forge default used" "forge-default" "$base"
|
||||
|
||||
echo "== C. pr-merge transition validation (no hardcoded targets) =="
|
||||
mkmerge() { # $1 dir, $2 base, $3 head -> stubs pr-metadata; runs pr-merge; sets M_RC/M_OUT/M_ERR
|
||||
cp "$SCRIPT_DIR/pr-merge.sh" "$1/tools/git/pr-merge.sh" 2>/dev/null || true
|
||||
cat > "$1/tools/git/pr-metadata.sh" <<EOF
|
||||
#!/usr/bin/env bash
|
||||
printf '%s\n' '{"baseRefName":"$2","headRefName":"$3","headRefOid":"0123456789abcdef0123456789abcdef01234567","headRepository":"acme/widgets","title":"t","author":{"login":"a"}}'
|
||||
EOF
|
||||
chmod +x "$1/tools/git/pr-metadata.sh"
|
||||
cat > "$1/tools/git/ci-queue-wait.sh" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$1/tools/git/ci-queue-wait.sh"
|
||||
mkdir -p "$1/stub"
|
||||
printf '#!/usr/bin/env bash\nexit 0\n' > "$1/stub/curl"; chmod +x "$1/stub/curl"
|
||||
M_RC=0
|
||||
M_OUT="$(cd "$1" && env -i PATH="$1/stub:/usr/bin:/bin" HOME="$1/home" GITEA_TOKEN=stub-token \
|
||||
bash "$1/tools/git/pr-merge.sh" -n 7 --dry-run < /dev/null 2>"$1/merr.txt")" || M_RC=$?
|
||||
M_ERR="$(cat "$1/merr.txt")"
|
||||
}
|
||||
|
||||
new_sb; mkrepo "$SB/c1" <<< "$DECL_TR" "https://git.example.test/acme/widgets"
|
||||
mkmerge "$SB/c1" dev-trunk feature/x
|
||||
assert_rc "C1 feature->trunk under decl (custom trunk name, no hardcode)" 0 "$M_RC"
|
||||
assert_contains "C1 transition context printed" "$M_ERR" "transition OK under flow=trunk-release"
|
||||
|
||||
mkmerge "$SB/c1" prod-rel feature/x
|
||||
assert_rc "C2 feature->release REJECTED under decl" 1 "$M_RC"
|
||||
assert_contains "C2 names the declared transition rule" "$M_ERR" "not a declared transition"
|
||||
|
||||
mkmerge "$SB/c1" prod-rel dev-trunk
|
||||
assert_rc "C3 trunk->release promotion allowed" 0 "$M_RC"
|
||||
|
||||
new_sb; mkrepo "$SB/c4" <<< "" "https://git.example.test/acme/widgets"
|
||||
mkmerge "$SB/c4" main feature/x
|
||||
assert_rc "C4 absent decl: legacy main/next check still enforced (main ok)" 0 "$M_RC"
|
||||
assert_contains "C4 legacy warning present" "$M_ERR" "LEGACY assumptions"
|
||||
mkmerge "$SB/c4" trunk-x feature/x
|
||||
assert_rc "C4 absent decl: unknown target rejected by legacy check" 1 "$M_RC"
|
||||
|
||||
new_sb; mkrepo "$SB/c5" <<< "$DECL_TR" "https://git.example.test/acme/wrong"
|
||||
mkmerge "$SB/c5" dev-trunk feature/x
|
||||
assert_rc "C5 remote mismatch refuses the merge" 1 "$M_RC"
|
||||
assert_contains "C5 names 5.3" "$M_ERR" "canonical_remote"
|
||||
|
||||
echo "== D. mosaic-worktree staged rule (4.4/4.5) =="
|
||||
mkwt() { # $1 dir: home outside the repo so derivation passes assert_not_home
|
||||
mv "$1/home" "$SB/wthome" 2>/dev/null || true
|
||||
}
|
||||
new_sb; mkrepo "$SB/d1" <<< '{ not json'; mkwt "$SB/d1"
|
||||
WT_RC=0
|
||||
WT_OUT="$(cd "$SB/d1" && env -i PATH="/usr/bin:/bin" HOME="$SB/wthome" \
|
||||
bash "$SB/d1/tools/git/mosaic-worktree.sh" new feat2 < /dev/null 2>"$SB/d1/wterr.txt")" || WT_RC=$?
|
||||
assert_rc "D1 invalid decl fails branch-creation loud" 1 "$WT_RC"
|
||||
assert_contains "D1 names the invalid declaration" "$(cat "$SB/d1/wterr.txt")" "INVALID"
|
||||
|
||||
new_sb; mkrepo "$SB/d2" <<< ""; mkwt "$SB/d2"
|
||||
WT_RC=0
|
||||
WT_OUT="$(cd "$SB/d2" && env -i PATH="/usr/bin:/bin" HOME="$SB/wthome" \
|
||||
bash "$SB/d2/tools/git/mosaic-worktree.sh" new feat3 < /dev/null 2>"$SB/d2/wterr.txt")" || WT_RC=$?
|
||||
assert_rc "D2 absent decl: warn + proceed" 0 "$WT_RC"
|
||||
assert_contains "D2 loud warning present" "$(cat "$SB/d2/wterr.txt")" "LEGACY assumptions"
|
||||
|
||||
new_sb; mkrepo "$SB/d3" <<< "$DECL_TR"; mkwt "$SB/d3"
|
||||
WT_RC=0
|
||||
WT_OUT="$(cd "$SB/d3" && env -i PATH="/usr/bin:/bin" HOME="$SB/wthome" \
|
||||
bash "$SB/d3/tools/git/mosaic-worktree.sh" new feat4 < /dev/null 2>"$SB/d3/wterr.txt")" || WT_RC=$?
|
||||
assert_rc "D3 valid decl: policy advisory + proceed" 0 "$WT_RC"
|
||||
assert_contains "D3 precreated policy note" "$(cat "$SB/d3/wterr.txt")" "orchestrator-precreated"
|
||||
assert_contains "D3 placement stays derived (no decl path used)" "$(cat "$SB/d3/wterr.txt")" "TRANSITIONAL"
|
||||
|
||||
echo "== E. ci-queue-wait route context (C4: context only, never a gate) =="
|
||||
new_sb; mkrepo "$SB/e1" <<< "$DECL_TR" "https://git.example.test/acme/widgets"
|
||||
git -C "$SB/e1" checkout -q -b dev-trunk 2>/dev/null || { git -C "$SB/e1" branch -q dev-trunk; git -C "$SB/e1" checkout -q dev-trunk; }
|
||||
E_RC=0
|
||||
E_OUT="$(cd "$SB/e1" && env -i PATH="/usr/bin:/bin" HOME="$SB/e1/home" \
|
||||
bash "$SB/e1/tools/git/ci-queue-wait.sh" < /dev/null 2>"$SB/e1/eerr.txt")" || E_RC=$?
|
||||
E_ERR="$(cat "$SB/e1/eerr.txt")"
|
||||
assert_contains "E1 route context names trunk head" "$E_ERR" "'dev-trunk' is a trunk (integration head) head"
|
||||
assert_contains "E1 context names the flow" "$E_ERR" "flow=trunk-release"
|
||||
|
||||
new_sb; mkrepo "$SB/e2" <<< "" "https://git.example.test/acme/widgets"
|
||||
E_RC=0
|
||||
E_OUT="$(cd "$SB/e2" && env -i PATH="/usr/bin:/bin" HOME="$SB/e2/home" \
|
||||
bash "$SB/e2/tools/git/ci-queue-wait.sh" < /dev/null 2>"$SB/e2/eerr.txt")" || E_RC=$?
|
||||
assert_not_contains "E2 absence is SILENT for the guard (N4)" "$(cat "$SB/e2/eerr.txt")" "repo-decl"
|
||||
|
||||
cleanup_all
|
||||
assert_count "final: zero scratch residue" 0 "$(ls -d "$TROOT"/wp5b-test.* 2>/dev/null | wc -l | tr -d ' ')"
|
||||
|
||||
echo
|
||||
echo "pass=$PASS fail=$FAIL"
|
||||
if [ "$FAIL" -gt 0 ]; then
|
||||
echo "FAILED CASES:$FAILED_CASES"
|
||||
exit 1
|
||||
fi
|
||||
echo "ALL GREEN"
|
||||
@@ -25,7 +25,7 @@
|
||||
"lint": "eslint src",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell",
|
||||
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 framework/tools/quality/scripts/test-framework-drift-check.py && bash framework/tools/quality/scripts/test-framework-drift-doctor.sh && bash framework/systemd/user/test-fleet-units.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/promotion_binding_unittest.py && python3 src/lease-broker/promotion_trigger_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/receipt_observer_client_unittest.py && python3 src/lease-broker/invariant_r_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/lease-broker/revoke_noop_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-edit.sh && bash framework/tools/git/test-pr-create-fallback-default-base.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-no-status.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-ci-queue-wait-no-ci-expected.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-no-ci-expected.sh && bash framework/tools/git/test-pr-merge-fork-ci-status.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/git/test-explain-diagnostic-status-neutral.sh && bash framework/tools/git/test-detect-platform-outside-repo.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/_scripts/test-mosaic-init-rce.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh && bash framework/tools/glpi/test-list-http-status.sh && bash framework/tools/orchestrator/test-board-roll.sh && bash framework/tools/woodpecker/test-ci-wait-exit-matrix.sh && bash framework/tools/_scripts/test-fleet-transport-check.sh && bash framework/tools/_scripts/test-brain-home-check.sh && bash framework/tools/_scripts/test-structure-anchor-check.sh && bash framework/tools/fleet/test-agent-session-broker-preflight.sh"
|
||||
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 framework/tools/quality/scripts/test-framework-drift-check.py && bash framework/tools/quality/scripts/test-framework-drift-doctor.sh && bash framework/systemd/user/test-fleet-units.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/promotion_binding_unittest.py && python3 src/lease-broker/promotion_trigger_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/receipt_observer_client_unittest.py && python3 src/lease-broker/invariant_r_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/lease-broker/revoke_noop_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-edit.sh && bash framework/tools/git/test-pr-create-fallback-default-base.sh && bash framework/tools/git/test-repo-decl-consumption.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-no-status.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-ci-queue-wait-no-ci-expected.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-no-ci-expected.sh && bash framework/tools/git/test-pr-merge-fork-ci-status.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/git/test-explain-diagnostic-status-neutral.sh && bash framework/tools/git/test-detect-platform-outside-repo.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/_scripts/test-mosaic-init-rce.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh && bash framework/tools/glpi/test-list-http-status.sh && bash framework/tools/orchestrator/test-board-roll.sh && bash framework/tools/woodpecker/test-ci-wait-exit-matrix.sh && bash framework/tools/_scripts/test-fleet-transport-check.sh && bash framework/tools/_scripts/test-brain-home-check.sh && bash framework/tools/_scripts/test-structure-anchor-check.sh && bash framework/tools/fleet/test-agent-session-broker-preflight.sh && bash framework/tools/fleet/test-agent-session-legacy-socket-guard.sh && bash framework/tools/git/test-grant-reviewer.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mosaicstack/brain": "workspace:*",
|
||||
|
||||
@@ -211,12 +211,23 @@ describe('mosaic fleet install — roster v2', (): void => {
|
||||
describe('[email protected]', (): void => {
|
||||
const unitPath = resolve(process.cwd(), 'framework', 'systemd', 'user', '[email protected]');
|
||||
|
||||
/** The single `ConditionPathExists=` value declared by the unit template. */
|
||||
async function conditionPath(): Promise<string> {
|
||||
/**
|
||||
* Every `ConditionPathExists=` value declared by the unit template, in file
|
||||
* order, `|` triggering prefix included.
|
||||
*
|
||||
* Until #1410 this helper pinned `toHaveLength(1)` — a count assertion, not
|
||||
* a content assertion, and the count pin was itself the defect: when #1408
|
||||
* required a second triggering line (the `%h/.mosaic` brain-home shape),
|
||||
* this spec was a second consumer of the unit template that the shell suite
|
||||
* and the enumeration guard could not see, so the fix failed here first
|
||||
* (CI 2651 — the installation-documentation.spec.ts lesson again). Assert
|
||||
* content per line, never count.
|
||||
*/
|
||||
async function conditionPaths(): Promise<string[]> {
|
||||
const unit = await readFile(unitPath, 'utf8');
|
||||
const matches = unit.match(/^ConditionPathExists=(.+)$/gm) ?? [];
|
||||
expect(matches).toHaveLength(1);
|
||||
return matches[0]!.slice('ConditionPathExists='.length).trim();
|
||||
expect(matches.length).toBeGreaterThan(0);
|
||||
return matches.map((line) => line.slice('ConditionPathExists='.length).trim());
|
||||
}
|
||||
|
||||
it('will not attempt a seat before the reconciler has written its env', async (): Promise<void> => {
|
||||
@@ -224,7 +235,26 @@ describe('[email protected]', (): void => {
|
||||
// unit (WantedBy=default.target) but does not start it, so without this
|
||||
// condition a reboot between `install` and the first `apply` would run
|
||||
// ExecStart against an absent env file and fail every seat unit.
|
||||
expect(await conditionPath()).toBe('%h/.config/mosaic/fleet/agents/%i.env.generated');
|
||||
//
|
||||
// Two lines since #1408: the env projection lives under %h/.config/mosaic
|
||||
// on framework-home hosts and under %h/.mosaic on brain-home hosts. Both
|
||||
// carry the `|` triggering prefix — systemd ANDs same-type conditions
|
||||
// unless every line is triggering (then they OR), and a bare spelling
|
||||
// would demand BOTH home shapes on one host, which is never true, so
|
||||
// every seat would silently skip.
|
||||
expect(await conditionPaths()).toEqual([
|
||||
'|%h/.config/mosaic/fleet/agents/%i.env.generated',
|
||||
'|%h/.mosaic/fleet/agents/%i.env.generated',
|
||||
]);
|
||||
});
|
||||
|
||||
it('refuses the bare ANDed spelling on every condition line', async (): Promise<void> => {
|
||||
// Invariant ported from test-fleet-units.sh, held separately from the
|
||||
// literal pin above so it survives future edits to the path set: every
|
||||
// ConditionPathExists line must stay triggering (`|`).
|
||||
for (const value of await conditionPaths()) {
|
||||
expect(value.startsWith('|')).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -246,10 +276,18 @@ describe('[email protected]', (): void => {
|
||||
*/
|
||||
it('guards exactly the file the fleet writes, so the two cannot drift apart', async (): Promise<void> => {
|
||||
const mosaicHome = await v2Home();
|
||||
const rendered = (await conditionPath()).replace('%h', tempHome!).replace('%i', 'coder0');
|
||||
const rendered = (await conditionPaths()).map((value) =>
|
||||
value.replace(/^\|/, '').replace('%h', tempHome!).replace('%i', 'coder0'),
|
||||
);
|
||||
|
||||
// The path an installed fleet actually places for this agent.
|
||||
expect(rendered).toBe(join(mosaicHome, 'fleet', 'agents', 'coder0.env.generated'));
|
||||
// Per home shape, the guard must render to exactly the file the
|
||||
// reconciler writes there: mosaicHome (%h/.config/mosaic) on
|
||||
// framework-home hosts, %h/.mosaic on brain-home hosts (#1408) — each
|
||||
// line pinned to its file, not merely present.
|
||||
expect(rendered).toEqual([
|
||||
join(mosaicHome, 'fleet', 'agents', 'coder0.env.generated'),
|
||||
join(tempHome!, '.mosaic', 'fleet', 'agents', 'coder0.env.generated'),
|
||||
]);
|
||||
});
|
||||
|
||||
it('guards a real failure — the launcher rejects an absent generated env', async (): Promise<void> => {
|
||||
|
||||
@@ -172,9 +172,10 @@ export function registerGatewayCommand(program: Command): void {
|
||||
.command('recover-token')
|
||||
.description('Recover an admin token — prompts for login if no valid session exists')
|
||||
.option('-g, --gateway <url>', 'Gateway URL (overrides meta.json)')
|
||||
.action(async (cmdOpts: { gateway?: string }) => {
|
||||
.option('-e, --email <email>', 'Headless: account email (password read from stdin line 2)')
|
||||
.action(async (cmdOpts: { gateway?: string; email?: string }) => {
|
||||
const { runRecoverToken } = await import('./gateway/token-ops.js');
|
||||
await runRecoverToken(cmdOpts.gateway);
|
||||
await runRecoverToken(cmdOpts.gateway, cmdOpts.email);
|
||||
});
|
||||
|
||||
// ─── logs ───────────────────────────────────────────────────────────────
|
||||
@@ -202,9 +203,14 @@ export function registerGatewayCommand(program: Command): void {
|
||||
|
||||
gw.command('uninstall')
|
||||
.description('Uninstall the gateway daemon and optionally remove data')
|
||||
.action(async () => {
|
||||
.option(
|
||||
'-y, --yes',
|
||||
'Headless: skip the confirmation prompt (required when stdin is not a TTY)',
|
||||
)
|
||||
.option('--remove-data', 'Also remove all gateway data (never implied by --yes)')
|
||||
.action(async (cmdOpts: { yes?: boolean; removeData?: boolean }) => {
|
||||
const { runUninstall } = await import('./gateway/uninstall.js');
|
||||
await runUninstall();
|
||||
await runUninstall(cmdOpts);
|
||||
});
|
||||
|
||||
// ─── doctor ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -95,11 +95,17 @@ export async function runInstall(opts: InstallOpts): Promise<void> {
|
||||
// fatal (#1392): an install that reports success over an empty/partial
|
||||
// database is the exact T63 failure this command must never reproduce.
|
||||
let verifyResult: VerifyResult | undefined;
|
||||
let verificationThrew = false;
|
||||
try {
|
||||
const { runPostInstallVerification } = await import('./verify.js');
|
||||
verifyResult = await runPostInstallVerification(configResult.host, configResult.port);
|
||||
} catch {
|
||||
// Non-fatal — verification is a courtesy
|
||||
} catch (err) {
|
||||
// Health/token/bootstrap courtesy failures are non-fatal, but a THROWN
|
||||
// schema verification must not let install report success either (N2,
|
||||
// rev-code-02 review 285): mark it and treat as fatal below.
|
||||
verificationThrew = true;
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
prompter.warn(`Post-install verification errored: ${msg}`);
|
||||
}
|
||||
if (verifyResult && verifyResult.schemaMigrated === false) {
|
||||
prompter.warn(
|
||||
@@ -107,6 +113,12 @@ export async function runInstall(opts: InstallOpts): Promise<void> {
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
if (verificationThrew) {
|
||||
prompter.warn(
|
||||
'Gateway install ABORTED: post-install verification errored (see above); refusing to report success on an unverified database.',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (err) {
|
||||
// Stages normally return structured results for expected failures.
|
||||
// Anything that reaches here is an unexpected runtime error — render a
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Readable } from 'node:stream';
|
||||
import { readCredentialsFromPipedStdin } from './piped-credentials.js';
|
||||
|
||||
describe('readCredentialsFromPipedStdin — #1394 stdin dual path (real streams)', () => {
|
||||
it('reads exactly two lines; email trimmed, password as-is', async () => {
|
||||
const r = await readCredentialsFromPipedStdin(
|
||||
Readable.from([' [email protected] \n', 'pw with spaces \n']),
|
||||
);
|
||||
expect(r.email).toBe('[email protected]');
|
||||
expect(r.password).toBe('pw with spaces ');
|
||||
});
|
||||
|
||||
it('empty stdin → nulls (the headless-no-credentials shape)', async () => {
|
||||
const r = await readCredentialsFromPipedStdin(Readable.from(['']));
|
||||
expect(r).toEqual({ email: null, password: null });
|
||||
});
|
||||
|
||||
it('single line only → email set, password null', async () => {
|
||||
const r = await readCredentialsFromPipedStdin(Readable.from(['only-email\n']));
|
||||
expect(r.email).toBe('only-email');
|
||||
expect(r.password).toBeNull();
|
||||
});
|
||||
|
||||
it('stops after two lines even if more follow', async () => {
|
||||
const r = await readCredentialsFromPipedStdin(
|
||||
Readable.from(['[email protected]\n', 'pw\n', 'extra\n', 'more\n']),
|
||||
);
|
||||
expect(r).toEqual({ email: '[email protected]', password: 'pw' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { createInterface } from 'node:readline';
|
||||
|
||||
/**
|
||||
* Read email + password as two lines from non-TTY stdin (the headless dual
|
||||
* path for callers that cannot pass argv: printf 'email\npassword\n' | …).
|
||||
* Caller gates on !isTTY; the password line is kept as-is (no trim —
|
||||
* whitespace may be intentional).
|
||||
*
|
||||
* Separate module (not login.ts) so tests can exercise the REAL reader
|
||||
* against real streams while token-ops specs mock this seam cleanly.
|
||||
*/
|
||||
export function readCredentialsFromPipedStdin(
|
||||
input: NodeJS.ReadableStream = process.stdin,
|
||||
): Promise<{ email: string | null; password: string | null }> {
|
||||
return new Promise((resolve) => {
|
||||
const lines: string[] = [];
|
||||
const rl = createInterface({ input });
|
||||
rl.on('line', (l) => {
|
||||
lines.push(l);
|
||||
if (lines.length >= 2) rl.close();
|
||||
});
|
||||
rl.on('close', () => {
|
||||
resolve({ email: (lines[0] ?? '').trim() || null, password: lines[1] ?? null });
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -16,11 +16,20 @@ vi.mock('./daemon.js', () => ({
|
||||
|
||||
vi.mock('./login.js', () => ({
|
||||
getGatewayUrl: vi.fn().mockReturnValue('http://localhost:14242'),
|
||||
// promptLine/promptSecret are used by ensureSession; return fixed values so tests don't block on stdin
|
||||
// promptLine/promptSecret are used by ensureSession on the TTY path; return fixed
|
||||
// values so tests never block on stdin.
|
||||
promptLine: vi.fn().mockResolvedValue('[email protected]'),
|
||||
promptSecret: vi.fn().mockResolvedValue('test-password'),
|
||||
}));
|
||||
|
||||
// #1394: non-TTY runs resolve credentials from piped stdin instead of prompts.
|
||||
vi.mock('./piped-credentials.js', () => ({
|
||||
readCredentialsFromPipedStdin: vi.fn().mockResolvedValue({
|
||||
email: '[email protected]',
|
||||
password: 'test-password',
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
@@ -65,7 +74,7 @@ describe('ensureSession', () => {
|
||||
expect(mockSignIn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prompts for credentials and signs in when stored session is invalid', async () => {
|
||||
it('resolves piped-stdin credentials and signs in when stored session is invalid', async () => {
|
||||
mockLoadSession.mockReturnValueOnce({ cookie: 'old-cookie', userId: 'u1', email: '[email protected]' });
|
||||
mockValidateSession.mockResolvedValueOnce(false);
|
||||
const newAuth = { cookie: fakeCookie, userId: 'u2', email: '[email protected]' };
|
||||
@@ -76,7 +85,7 @@ describe('ensureSession', () => {
|
||||
expect(mockSaveSession).toHaveBeenCalledWith(baseUrl, newAuth);
|
||||
});
|
||||
|
||||
it('prompts for credentials when no session exists', async () => {
|
||||
it('resolves piped-stdin credentials when no session exists', async () => {
|
||||
mockLoadSession.mockReturnValueOnce(null);
|
||||
const newAuth = { cookie: fakeCookie, userId: 'u2', email: '[email protected]' };
|
||||
mockSignIn.mockResolvedValueOnce(newAuth);
|
||||
@@ -84,6 +93,10 @@ describe('ensureSession', () => {
|
||||
const cookie = await ensureSession(baseUrl);
|
||||
expect(cookie).toBe(fakeCookie);
|
||||
expect(mockSignIn).toHaveBeenCalled();
|
||||
// The non-TTY path resolves credentials from the piped-stdin seam, not prompts.
|
||||
expect(
|
||||
vi.mocked(await import('./piped-credentials.js')).readCredentialsFromPipedStdin,
|
||||
).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('exits non-zero when signIn fails', async () => {
|
||||
@@ -111,7 +124,7 @@ describe('runRecoverToken', () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
it('prompts for login, mints a token, and persists it when no session exists', async () => {
|
||||
it('signs in via piped stdin, mints a token, and persists it when no session exists', async () => {
|
||||
mockLoadSession.mockReturnValueOnce(null);
|
||||
const newAuth = { cookie: fakeCookie, userId: 'u2', email: '[email protected]' };
|
||||
mockSignIn.mockResolvedValueOnce(newAuth);
|
||||
|
||||
@@ -153,4 +153,29 @@ describe('resolveSchemaCheckConfigPath', () => {
|
||||
if (prevHome !== undefined) vi.stubEnv('HOME', prevHome);
|
||||
}
|
||||
});
|
||||
|
||||
it('gives MOSAIC_CONFIG NO authority (N1, review 285): env never overrides file resolution', async () => {
|
||||
const { resolveSchemaCheckConfigPath } = await import('./schema-check.js');
|
||||
const daemonDir = mkdtempSync(join(tmpdir(), 'schema-check-env-'));
|
||||
tmpDirs.push(daemonDir);
|
||||
const daemonHome = join(daemonDir, '.config', 'mosaic', 'gateway');
|
||||
mkdirSync(daemonHome, { recursive: true });
|
||||
writeFileSync(join(daemonHome, 'mosaic.config.json'), JSON.stringify(LOCAL_CFG));
|
||||
// A stale env var pointing at a DIFFERENT file must be ignored entirely:
|
||||
const decoyDir = mkdtempSync(join(tmpdir(), 'schema-check-decoy-'));
|
||||
tmpDirs.push(decoyDir);
|
||||
const decoyPath = join(decoyDir, 'mosaic.config.json');
|
||||
writeFileSync(decoyPath, JSON.stringify(STANDALONE_CFG));
|
||||
|
||||
const prevHome = process.env['HOME'];
|
||||
vi.stubEnv('HOME', daemonDir);
|
||||
vi.stubEnv('MOSAIC_CONFIG', decoyPath);
|
||||
try {
|
||||
const resolved = resolveSchemaCheckConfigPath();
|
||||
expect(resolved).toBe(join(daemonHome, 'mosaic.config.json'));
|
||||
expect(resolved).not.toBe(decoyPath);
|
||||
} finally {
|
||||
if (prevHome !== undefined) vi.stubEnv('HOME', prevHome);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -53,8 +53,11 @@ export const SCHEMA_FAIL_REMEDIATION = [
|
||||
*/
|
||||
export function resolveSchemaCheckConfigPath(explicit?: string): string | undefined {
|
||||
if (explicit) return resolve(explicit);
|
||||
// NOTE: no env-var candidate, deliberately. apps/gateway/src/env.ts gives env
|
||||
// NO config authority (a stale MOSAIC_CONFIG could verify a database the
|
||||
// daemon never reads — rev-code-02 review 285, note N1). Resolution order
|
||||
// mirrors the daemon's file priorities only.
|
||||
const candidates = [
|
||||
process.env['MOSAIC_CONFIG'],
|
||||
join(homedir(), '.config', 'mosaic', 'gateway', 'mosaic.config.json'), // daemon-written
|
||||
resolve(process.cwd(), 'mosaic.config.json'),
|
||||
join(homedir(), '.mosaic', 'mosaic.config.json'),
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('../../auth.js', () => ({
|
||||
loadSession: vi.fn(),
|
||||
validateSession: vi.fn(),
|
||||
signIn: vi.fn(),
|
||||
saveSession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./login.js', () => ({
|
||||
getGatewayUrl: vi.fn().mockReturnValue('http://localhost:14242'),
|
||||
promptLine: vi.fn(),
|
||||
promptSecret: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./piped-credentials.js', () => ({
|
||||
readCredentialsFromPipedStdin: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./daemon.js', () => ({
|
||||
readMeta: vi.fn(),
|
||||
writeMeta: vi.fn(),
|
||||
}));
|
||||
|
||||
import { ensureSession } from './token-ops.js';
|
||||
import { loadSession, validateSession, signIn, saveSession } from '../../auth.js';
|
||||
import { promptLine, promptSecret } from './login.js';
|
||||
import { readCredentialsFromPipedStdin } from './piped-credentials.js';
|
||||
|
||||
const URL = 'http://localhost:14242';
|
||||
|
||||
function asNonTTY(): void {
|
||||
Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true });
|
||||
}
|
||||
|
||||
describe('ensureSession — #1394 credential precedence (flag > piped stdin > prompt)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(loadSession).mockReturnValue(null);
|
||||
asNonTTY();
|
||||
});
|
||||
|
||||
it('stored valid session wins; no credentials touched', async () => {
|
||||
vi.mocked(loadSession).mockReturnValue({ cookie: 'SESS', email: '[email protected]' } as never);
|
||||
vi.mocked(validateSession).mockResolvedValue(true);
|
||||
await expect(ensureSession(URL)).resolves.toBe('SESS');
|
||||
expect(signIn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('flag email + stdin password: FLAG wins for email, stdin supplies the password', async () => {
|
||||
vi.mocked(signIn).mockResolvedValue({ cookie: 'NEW', email: '[email protected]' } as never);
|
||||
vi.mocked(readCredentialsFromPipedStdin).mockResolvedValue({
|
||||
email: '[email protected]',
|
||||
password: 'stdin-pw',
|
||||
});
|
||||
|
||||
await ensureSession(URL, { email: '[email protected]' });
|
||||
|
||||
expect(signIn).toHaveBeenCalledWith(URL, '[email protected]', 'stdin-pw');
|
||||
expect(promptLine).not.toHaveBeenCalled();
|
||||
expect(promptSecret).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stdin-only path (no flag): both credentials from piped lines', async () => {
|
||||
vi.mocked(signIn).mockResolvedValue({ cookie: 'NEW2', email: '[email protected]' } as never);
|
||||
vi.mocked(readCredentialsFromPipedStdin).mockResolvedValue({
|
||||
email: '[email protected]',
|
||||
password: 'spw',
|
||||
});
|
||||
|
||||
await ensureSession(URL);
|
||||
expect(signIn).toHaveBeenCalledWith(URL, '[email protected]', 'spw');
|
||||
});
|
||||
|
||||
it('no credentials headless → exit(2) with --email guidance; signIn untouched', async () => {
|
||||
vi.mocked(readCredentialsFromPipedStdin).mockResolvedValue({ email: null, password: null });
|
||||
const exit = vi.spyOn(process, 'exit').mockImplementation((() => {
|
||||
throw new Error('EXIT');
|
||||
}) as never);
|
||||
const err = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
await expect(ensureSession(URL)).rejects.toThrow('EXIT');
|
||||
expect(exit).toHaveBeenCalledWith(2);
|
||||
expect(err).toHaveBeenCalledWith(expect.stringContaining('--email'));
|
||||
expect(signIn).not.toHaveBeenCalled();
|
||||
|
||||
exit.mockRestore();
|
||||
err.mockRestore();
|
||||
});
|
||||
|
||||
it('successful sign-in persists the session', async () => {
|
||||
vi.mocked(signIn).mockResolvedValue({ cookie: 'C', email: '[email protected]' } as never);
|
||||
vi.mocked(readCredentialsFromPipedStdin).mockResolvedValue({
|
||||
email: '[email protected]',
|
||||
password: 'pw',
|
||||
});
|
||||
|
||||
await ensureSession(URL);
|
||||
expect(saveSession).toHaveBeenCalledWith(URL, expect.anything());
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { loadSession, validateSession, signIn, saveSession } from '../../auth.js';
|
||||
import { readMeta, writeMeta } from './daemon.js';
|
||||
import { getGatewayUrl, promptLine, promptSecret } from './login.js';
|
||||
import { readCredentialsFromPipedStdin } from './piped-credentials.js';
|
||||
|
||||
interface MintedToken {
|
||||
id: string;
|
||||
@@ -107,8 +108,24 @@ export async function requireSession(gatewayUrl: string): Promise<string> {
|
||||
* Ensure a valid session for the gateway, prompting for credentials if needed.
|
||||
* On sign-in failure, prints the error and exits non-zero.
|
||||
* Returns the session cookie.
|
||||
*
|
||||
* Credential precedence when sign-in is needed (#1394):
|
||||
* 1. explicit opts (--email flag; highest)
|
||||
* 2. non-TTY stdin — first line email, second line password (headless dual
|
||||
* path for callers without argv access: printf 'email\npassword\n' | …)
|
||||
* 3. interactive prompt (TTY only)
|
||||
*/
|
||||
export async function ensureSession(gatewayUrl: string): Promise<string> {
|
||||
export interface SessionCredentialOptions {
|
||||
/** Email from an explicit flag (argv). Highest precedence. */
|
||||
email?: string;
|
||||
/** Password from an explicit source. Rare; passwords normally come via stdin/prompt. */
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export async function ensureSession(
|
||||
gatewayUrl: string,
|
||||
opts: SessionCredentialOptions = {},
|
||||
): Promise<string> {
|
||||
// Try the stored session first
|
||||
const session = loadSession(gatewayUrl);
|
||||
if (session) {
|
||||
@@ -119,10 +136,25 @@ export async function ensureSession(gatewayUrl: string): Promise<string> {
|
||||
console.log(`No session found for ${gatewayUrl}. Please sign in.`);
|
||||
}
|
||||
|
||||
// Prompt for credentials — password must not be echoed to the terminal
|
||||
const email = await promptLine('Email: ');
|
||||
// Do not trim password — it may contain intentional leading/trailing whitespace
|
||||
const password = await promptSecret('Password: ');
|
||||
let email = opts.email;
|
||||
let password = opts.password;
|
||||
if ((!email || !password) && !process.stdin.isTTY) {
|
||||
const piped = await readCredentialsFromPipedStdin();
|
||||
email = email ?? piped.email ?? undefined;
|
||||
password = password ?? piped.password ?? undefined;
|
||||
}
|
||||
if (!email || !password) {
|
||||
if (!process.stdin.isTTY) {
|
||||
console.error(
|
||||
'No valid session and no credentials available headlessly. Provide --email plus ' +
|
||||
"a password line on stdin (printf 'email\\npassword\\n' | …), or run interactively.",
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
email = await promptLine('Email: ');
|
||||
// Do not trim password — it may contain intentional leading/trailing whitespace
|
||||
password = await promptSecret('Password: ');
|
||||
}
|
||||
|
||||
const auth = await signIn(gatewayUrl, email, password).catch((err: unknown) => {
|
||||
console.error(err instanceof Error ? err.message : String(err));
|
||||
@@ -146,11 +178,12 @@ export async function runRotateToken(gatewayUrl?: string): Promise<void> {
|
||||
}
|
||||
|
||||
/**
|
||||
* `mosaic gateway config recover-token` — prompts for login if no session exists.
|
||||
* `mosaic gateway config recover-token` — signs in if no session exists.
|
||||
* Passes the --email flag through to ensureSession (#1394 dual path).
|
||||
*/
|
||||
export async function runRecoverToken(gatewayUrl?: string): Promise<void> {
|
||||
export async function runRecoverToken(gatewayUrl?: string, email?: string): Promise<void> {
|
||||
const url = getGatewayUrl(gatewayUrl);
|
||||
const cookie = await ensureSession(url);
|
||||
const cookie = await ensureSession(url, { email });
|
||||
const label = `CLI recovery token (${new Date().toISOString().slice(0, 16).replace('T', ' ')})`;
|
||||
const minted = await mintAdminToken(url, cookie, label);
|
||||
persistToken(url, minted);
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
|
||||
vi.mock('./daemon.js', () => ({
|
||||
GATEWAY_HOME: '/tmp/u-test-gateway-home',
|
||||
getDaemonPid: vi.fn().mockReturnValue(null),
|
||||
readMeta: vi.fn(),
|
||||
stopDaemon: vi.fn(),
|
||||
uninstallGatewayPackage: vi.fn(),
|
||||
}));
|
||||
|
||||
import { runUninstall } from './uninstall.js';
|
||||
import { readMeta, uninstallGatewayPackage } from './daemon.js';
|
||||
|
||||
describe('gateway uninstall — #1390 headless semantics', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('non-TTY without --yes FAILS LOUD (exit 1, nothing touched)', async () => {
|
||||
vi.mocked(readMeta).mockReturnValue({
|
||||
version: '0.0.7',
|
||||
installedAt: '',
|
||||
entryPoint: '',
|
||||
host: 'localhost',
|
||||
port: 14242,
|
||||
});
|
||||
const exit = vi.spyOn(process, 'exit').mockImplementation((() => {
|
||||
throw new Error('EXIT');
|
||||
}) as never);
|
||||
const err = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
await expect(runUninstall()).rejects.toThrow('EXIT');
|
||||
expect(exit).toHaveBeenCalledWith(1);
|
||||
expect(err).toHaveBeenCalledWith(expect.stringContaining('stdin is not a TTY'));
|
||||
expect(uninstallGatewayPackage).not.toHaveBeenCalled();
|
||||
|
||||
exit.mockRestore();
|
||||
err.mockRestore();
|
||||
});
|
||||
|
||||
it('--yes proceeds headlessly WITHOUT removing data (never implied)', async () => {
|
||||
const meta = {
|
||||
version: '0.0.7',
|
||||
installedAt: '',
|
||||
entryPoint: '',
|
||||
host: 'localhost',
|
||||
port: 14242,
|
||||
};
|
||||
vi.mocked(readMeta).mockReturnValue(meta);
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
|
||||
await runUninstall({ yes: true });
|
||||
|
||||
expect(uninstallGatewayPackage).toHaveBeenCalledTimes(1);
|
||||
expect(log).toHaveBeenCalledWith(expect.stringContaining('Gateway data kept'));
|
||||
log.mockRestore();
|
||||
});
|
||||
|
||||
it('--yes --remove-data removes data headlessly', async () => {
|
||||
vi.mocked(readMeta).mockReturnValue({
|
||||
version: '0.0.7',
|
||||
installedAt: '',
|
||||
entryPoint: '',
|
||||
host: 'localhost',
|
||||
port: 14242,
|
||||
});
|
||||
mkdirSync('/tmp/u-test-gateway-home', { recursive: true }); // existsSync gate
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
|
||||
await runUninstall({ yes: true, removeData: true });
|
||||
|
||||
expect(uninstallGatewayPackage).toHaveBeenCalledTimes(1);
|
||||
expect(log).toHaveBeenCalledWith(expect.stringContaining('Gateway data removed'));
|
||||
log.mockRestore();
|
||||
});
|
||||
|
||||
it('no meta → clean no-op even with --yes', async () => {
|
||||
vi.mocked(readMeta).mockReturnValue(null);
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
await runUninstall({ yes: true });
|
||||
expect(log).toHaveBeenCalledWith('Gateway is not installed.');
|
||||
expect(uninstallGatewayPackage).not.toHaveBeenCalled();
|
||||
log.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -8,30 +8,65 @@ import {
|
||||
uninstallGatewayPackage,
|
||||
} from './daemon.js';
|
||||
|
||||
export async function runUninstall(): Promise<void> {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
export interface UninstallOptions {
|
||||
/** Skip the confirmation prompt (headless/scripted uninstall). */
|
||||
yes?: boolean;
|
||||
/** Also remove all gateway data at GATEWAY_HOME (never implied by --yes). */
|
||||
removeData?: boolean;
|
||||
}
|
||||
|
||||
export async function runUninstall(opts: UninstallOptions = {}): Promise<void> {
|
||||
const nonInteractive = Boolean(opts.yes) || process.env['MOSAIC_ASSUME_YES'] === '1';
|
||||
|
||||
// Non-TTY without explicit consent must FAIL LOUD, not quietly do nothing:
|
||||
// the pre-fix behavior (prompt on a closed stdin → default No → exit 0,
|
||||
// gateway untouched) reported success-by-silence to every scripted caller
|
||||
// (#1390). An explicit refusal beats a silent no-op.
|
||||
if (!nonInteractive && !process.stdin.isTTY) {
|
||||
console.error(
|
||||
'gateway uninstall: stdin is not a TTY and no --yes was given — refusing to ' +
|
||||
'run an interactive uninstall headlessly (nothing was changed). ' +
|
||||
'Use --yes (and --remove-data to also delete gateway data), or run from a terminal.',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const rl = nonInteractive
|
||||
? null
|
||||
: createInterface({ input: process.stdin, output: process.stdout });
|
||||
try {
|
||||
await doUninstall(rl);
|
||||
await doUninstall(rl as NonNullable<typeof rl>, opts, nonInteractive);
|
||||
} finally {
|
||||
rl.close();
|
||||
rl?.close();
|
||||
}
|
||||
}
|
||||
|
||||
function prompt(rl: ReturnType<typeof createInterface>, question: string): Promise<string> {
|
||||
function prompt(
|
||||
rl: NonNullable<ReturnType<typeof createInterface>>,
|
||||
question: string,
|
||||
): Promise<string> {
|
||||
return new Promise((resolve) => rl.question(question, resolve));
|
||||
}
|
||||
|
||||
async function doUninstall(rl: ReturnType<typeof createInterface>): Promise<void> {
|
||||
async function doUninstall(
|
||||
rl: ReturnType<typeof createInterface>,
|
||||
opts: UninstallOptions,
|
||||
nonInteractive: boolean,
|
||||
): Promise<void> {
|
||||
const meta = readMeta();
|
||||
if (!meta) {
|
||||
console.log('Gateway is not installed.');
|
||||
return;
|
||||
}
|
||||
|
||||
const answer = await prompt(rl, 'Uninstall Mosaic Gateway? [y/N] ');
|
||||
if (answer.toLowerCase() !== 'y') {
|
||||
console.log('Aborted.');
|
||||
return;
|
||||
if (nonInteractive) {
|
||||
console.log(`Uninstalling Mosaic Gateway (--yes${opts.removeData ? ' --remove-data' : ''})...`);
|
||||
} else {
|
||||
const answer = await prompt(rl, 'Uninstall Mosaic Gateway? [y/N] ');
|
||||
if (answer.toLowerCase() !== 'y') {
|
||||
console.log('Aborted.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Stop if running
|
||||
@@ -45,13 +80,20 @@ async function doUninstall(rl: ReturnType<typeof createInterface>): Promise<void
|
||||
}
|
||||
}
|
||||
|
||||
// Remove config/data
|
||||
const removeData = await prompt(rl, `Remove all gateway data at ${GATEWAY_HOME}? [y/N] `);
|
||||
if (removeData.toLowerCase() === 'y') {
|
||||
// Remove config/data. Interactive: ask. Headless: only with the explicit
|
||||
// flag — destructive recursion is never implied by --yes alone (#1390).
|
||||
let removeData = Boolean(opts.removeData);
|
||||
if (!nonInteractive) {
|
||||
const answer = await prompt(rl, `Remove all gateway data at ${GATEWAY_HOME}? [y/N] `);
|
||||
removeData = answer.toLowerCase() === 'y';
|
||||
}
|
||||
if (removeData) {
|
||||
if (existsSync(GATEWAY_HOME)) {
|
||||
rmSync(GATEWAY_HOME, { recursive: true, force: true });
|
||||
console.log('Gateway data removed.');
|
||||
}
|
||||
} else {
|
||||
console.log(`Gateway data kept at ${GATEWAY_HOME}.`);
|
||||
}
|
||||
|
||||
// Uninstall npm package
|
||||
|
||||
@@ -101,7 +101,7 @@ export async function runPostInstallVerification(
|
||||
const { runMigrations, getMigrationStatus } = await import('@mosaicstack/db');
|
||||
const result = await checkDatabaseSchema(
|
||||
{ runMigrations, getMigrationStatus },
|
||||
process.env['MOSAIC_CONFIG'],
|
||||
undefined, // resolver mirrors daemon file priorities; env has no config authority (N1)
|
||||
);
|
||||
if (result.status === 'ok') {
|
||||
ok(result.detail);
|
||||
|
||||
Reference in New Issue
Block a user