Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62b163a254 | ||
|
|
13e7398c2a | ||
|
|
8356e72c1c | ||
|
|
e18d13d36f | ||
|
|
60bc5d2022 | ||
|
|
ea91cfc421 | ||
|
|
acf640d00f | ||
|
|
431ead3a18 | ||
|
|
143ba0f57a |
+13
-155
@@ -1,156 +1,14 @@
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Mosaic — Environment Variables Reference
|
||||
# Copy this file to .env and fill in the values for your deployment.
|
||||
# Lines beginning with # are comments; optional vars are commented out.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Mosaic Stack standalone deployment (compose `stack` profile)
|
||||
# Copy to .env and adjust. Port overrides exist because the defaults
|
||||
# collide with common host services (and with the dev compose itself).
|
||||
PG_HOST_PORT=5433
|
||||
VALKEY_HOST_PORT=6380
|
||||
GATEWAY_HOST_PORT=14242
|
||||
# Registry image override (defaults to a local build of docker/gateway.Dockerfile):
|
||||
# GATEWAY_IMAGE=git.mosaicstack.dev/mosaicstack/stack/gateway:sha-acf640d
|
||||
|
||||
|
||||
# ─── Database (PostgreSQL 17 + pgvector) ─────────────────────────────────────
|
||||
# Full connection string used by the gateway, ORM, and migration runner.
|
||||
# Port 5433 avoids conflict with a host-side PostgreSQL instance.
|
||||
DATABASE_URL=postgresql://mosaic:mosaic@localhost:5433/mosaic
|
||||
|
||||
# Docker Compose host-port override for the PostgreSQL container (default: 5433)
|
||||
# PG_HOST_PORT=5433
|
||||
|
||||
|
||||
# ─── Queue (Valkey 8 / Redis-compatible) ─────────────────────────────────────
|
||||
# Port 6380 avoids conflict with a host-side Redis/Valkey instance.
|
||||
VALKEY_URL=redis://localhost:6380
|
||||
|
||||
# Docker Compose host-port override for the Valkey container (default: 6380)
|
||||
# VALKEY_HOST_PORT=6380
|
||||
|
||||
|
||||
# ─── Gateway ─────────────────────────────────────────────────────────────────
|
||||
# TCP port the NestJS/Fastify gateway listens on (default: 14242)
|
||||
GATEWAY_PORT=14242
|
||||
|
||||
# Comma-separated list of allowed CORS origins.
|
||||
# Must include the web app origin in production.
|
||||
GATEWAY_CORS_ORIGIN=http://localhost:3000
|
||||
|
||||
|
||||
# ─── Auth (BetterAuth) ───────────────────────────────────────────────────────
|
||||
# REQUIRED — random secret used to sign sessions and tokens.
|
||||
# Generate with: openssl rand -base64 32
|
||||
BETTER_AUTH_SECRET=change-me-to-a-random-32-char-string
|
||||
|
||||
# Public base URL of the gateway (used by BetterAuth for callback URLs)
|
||||
BETTER_AUTH_URL=http://localhost:14242
|
||||
|
||||
|
||||
# ─── Web App (SPA) ───────────────────────────────────────────────────────────
|
||||
# Directory holding the built SPA bundle (vite build output). When set, the
|
||||
# gateway serves the SPA same-origin; when unset (dev), run the Vite dev
|
||||
# server (pnpm --filter @mosaicstack/web dev), which proxies to the gateway.
|
||||
# safe-default: unset in dev — SPA serving is an opt-in production concern
|
||||
#WEB_DIST_DIR=apps/web/dist
|
||||
|
||||
|
||||
# ─── OpenTelemetry ───────────────────────────────────────────────────────────
|
||||
# OTLP HTTP endpoint (otel-collector or any OpenTelemetry-compatible backend)
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
|
||||
|
||||
# Service name shown in traces
|
||||
OTEL_SERVICE_NAME=mosaic-gateway
|
||||
|
||||
|
||||
# ─── AI Providers ────────────────────────────────────────────────────────────
|
||||
|
||||
# Ollama (local models — set OLLAMA_BASE_URL to enable)
|
||||
# OLLAMA_BASE_URL=http://localhost:11434
|
||||
# OLLAMA_HOST is a legacy alias for OLLAMA_BASE_URL
|
||||
# OLLAMA_HOST=http://localhost:11434
|
||||
# Comma-separated list of Ollama model IDs to register (default: llama3.2,codellama,mistral)
|
||||
# OLLAMA_MODELS=llama3.2,codellama,mistral
|
||||
|
||||
# Anthropic (claude-sonnet-4-6, claude-opus-4-6, claude-haiku-4-5)
|
||||
# ANTHROPIC_API_KEY=sk-ant-...
|
||||
|
||||
# OpenAI (gpt-4o, gpt-4o-mini, o3-mini)
|
||||
# OPENAI_API_KEY=sk-...
|
||||
|
||||
# Z.ai / GLM (glm-4.5, glm-4.5-air, glm-4.5-flash)
|
||||
# ZAI_API_KEY=...
|
||||
|
||||
# Custom providers — JSON array of provider configs
|
||||
# Format: [{"id":"<id>","baseUrl":"<url>","apiKey":"<key>","models":[{"id":"<model-id>","name":"<label>"}]}]
|
||||
# MOSAIC_CUSTOM_PROVIDERS=
|
||||
|
||||
|
||||
# ─── Embedding Service ───────────────────────────────────────────────────────
|
||||
# OpenAI-compatible embeddings endpoint (default: OpenAI)
|
||||
# EMBEDDING_API_URL=https://api.openai.com/v1
|
||||
# EMBEDDING_MODEL=text-embedding-3-small
|
||||
|
||||
|
||||
# ─── Log Summarization Service ───────────────────────────────────────────────
|
||||
# OpenAI-compatible chat completions endpoint for log summarization (default: OpenAI)
|
||||
# SUMMARIZATION_API_URL=https://api.openai.com/v1
|
||||
# SUMMARIZATION_MODEL=gpt-4o-mini
|
||||
|
||||
# Cron schedule for summarization job (default: every 6 hours)
|
||||
# SUMMARIZATION_CRON=0 */6 * * *
|
||||
|
||||
# Cron schedule for log tier management (default: daily at 03:00)
|
||||
# TIER_MANAGEMENT_CRON=0 3 * * *
|
||||
|
||||
|
||||
# ─── Agent ───────────────────────────────────────────────────────────────────
|
||||
# Filesystem sandbox root for agent file tools (default: process.cwd())
|
||||
# AGENT_FILE_SANDBOX_DIR=/var/lib/mosaic/sandbox
|
||||
|
||||
# Comma-separated list of tool names available to non-admin users.
|
||||
# Leave unset to allow all tools for all authenticated users.
|
||||
# AGENT_USER_TOOLS=read_file,list_directory,search_files
|
||||
|
||||
# System prompt injected into every agent session (optional)
|
||||
# AGENT_SYSTEM_PROMPT=You are a helpful assistant.
|
||||
|
||||
|
||||
# ─── MCP Servers ─────────────────────────────────────────────────────────────
|
||||
# JSON array of MCP server configs — set to enable MCP tool integration.
|
||||
# Each entry: {"name":"<id>","url":"<http-or-sse-url>"}
|
||||
# MCP_SERVERS=[{"name":"my-mcp","url":"http://localhost:3100/sse"}]
|
||||
|
||||
|
||||
# ─── Coordinator ─────────────────────────────────────────────────────────────
|
||||
# Root directory used to scope coordinator (worktree/repo) operations.
|
||||
# Defaults to the monorepo root auto-detected from process.cwd().
|
||||
# MOSAIC_WORKSPACE_ROOT=/home/user/projects/mosaic
|
||||
|
||||
|
||||
# ─── Discord Plugin (optional — set DISCORD_BOT_TOKEN to enable) ─────────────
|
||||
# DISCORD_BOT_TOKEN=
|
||||
# DISCORD_GUILD_ID=
|
||||
# DISCORD_GATEWAY_URL=http://localhost:14242
|
||||
|
||||
|
||||
# ─── Telegram Plugin (optional — set TELEGRAM_BOT_TOKEN to enable) ───────────
|
||||
# TELEGRAM_BOT_TOKEN=
|
||||
# TELEGRAM_GATEWAY_URL=http://localhost:14242
|
||||
|
||||
|
||||
# ─── SSO Providers (add credentials to enable) ───────────────────────────────
|
||||
|
||||
# --- Authentik (optional — set AUTHENTIK_CLIENT_ID to enable) ---
|
||||
# AUTHENTIK_ISSUER=https://auth.example.com/application/o/mosaic/
|
||||
# AUTHENTIK_CLIENT_ID=
|
||||
# AUTHENTIK_CLIENT_SECRET=
|
||||
|
||||
# --- WorkOS (optional — set WORKOS_CLIENT_ID to enable) ---
|
||||
# WORKOS_ISSUER=https://your-company.authkit.app
|
||||
# WORKOS_CLIENT_ID=client_...
|
||||
# WORKOS_CLIENT_SECRET=sk_live_...
|
||||
|
||||
# --- Keycloak (optional — set KEYCLOAK_CLIENT_ID to enable) ---
|
||||
# KEYCLOAK_ISSUER=https://auth.example.com/realms/master
|
||||
# Legacy alternative if you prefer to compose the issuer from separate vars:
|
||||
# KEYCLOAK_URL=https://auth.example.com
|
||||
# KEYCLOAK_REALM=master
|
||||
# KEYCLOAK_CLIENT_ID=mosaic
|
||||
# KEYCLOAK_CLIENT_SECRET=
|
||||
|
||||
# The web login page discovers configured providers dynamically from
|
||||
# GET /api/sso/providers. No NEXT_PUBLIC_* provider feature flag is required.
|
||||
# Optional explicit dogfood overlay (docker-compose.dogfood.yml).
|
||||
# Both paths are required when that overlay is used. Use a dedicated next-based
|
||||
# worktree and the external home of the unprivileged stack-dogfood seat.
|
||||
# MOSAIC_DOGFOOD_WORKTREE=/home/example/src/mosaic-stack-worktrees/dogfood-1487
|
||||
# MOSAIC_DOGFOOD_SEAT_HOME=/home/example/.mosaic/fleet/agents/stack-dogfood
|
||||
|
||||
@@ -208,6 +208,46 @@ mosaic telemetry upload # Dry-run unless opted in
|
||||
|
||||
Consent state is persisted in config. Remote upload is a no-op until you run `mosaic telemetry opt-in`.
|
||||
|
||||
## Standalone container deployment
|
||||
|
||||
The `stack` profile runs PostgreSQL, Valkey, the gateway, and the bundled webUI. Copy
|
||||
`.env.example` to `.env`, generate `BETTER_AUTH_SECRET`, then start the profile:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
printf 'BETTER_AUTH_SECRET=%s\n' "$(openssl rand -hex 32)" >> .env
|
||||
docker compose --profile stack up -d
|
||||
```
|
||||
|
||||
The optional dogfood overlay gives one dedicated in-stack agent a writable stack
|
||||
worktree and its own read-only credential slot. It does not mount the fleet brain or
|
||||
any other seat. Prepare a `next`-based worktree and an unprivileged `stack-dogfood`
|
||||
seat outside the container, then set these paths in `.env`:
|
||||
|
||||
```dotenv
|
||||
MOSAIC_DOGFOOD_WORKTREE=/path/to/mosaic-stack-worktrees/dogfood-1487
|
||||
MOSAIC_DOGFOOD_SEAT_HOME=/path/to/.mosaic/fleet/agents/stack-dogfood
|
||||
```
|
||||
|
||||
The seat home must contain only that seat's credential at
|
||||
`secrets/gitea-mosaicstack-stack-dogfood.token`. Never place the token value in
|
||||
`.env`. Start the overlay with:
|
||||
|
||||
```bash
|
||||
docker compose \
|
||||
-f docker-compose.yml \
|
||||
-f docker-compose.dogfood.yml \
|
||||
--profile stack up -d
|
||||
```
|
||||
|
||||
The overlay scopes regular-agent tools to the mounted checkout. For issue and PR
|
||||
operations, instruct the agent to use `/opt/mosaic/tools/git/`. The gateway image
|
||||
configures `git-credential-mosaic` as Git's system credential helper, so pushes and
|
||||
`pr-create.sh` resolve only the `stack-dogfood` slot and fail if it is absent.
|
||||
|
||||
This deployment route is separate from the local source-development restrictions
|
||||
below.
|
||||
|
||||
## Development
|
||||
|
||||
### Prerequisites
|
||||
|
||||
@@ -25,6 +25,7 @@ import { HarnessModule } from './harness/harness.module.js';
|
||||
import { ReloadModule } from './reload/reload.module.js';
|
||||
import { WorkspaceModule } from './workspace/workspace.module.js';
|
||||
import { HierarchyModule } from './hierarchy/hierarchy.module.js';
|
||||
import { EnrollmentModule } from './enrollment/enrollment.module.js';
|
||||
import { QueueModule } from './queue/queue.module.js';
|
||||
import { FederationModule } from './federation/federation.module.js';
|
||||
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||
@@ -67,6 +68,7 @@ const federationEnabled = loadConfig(resolveGatewayConfigPath()).tier === 'feder
|
||||
ReloadModule,
|
||||
WorkspaceModule,
|
||||
HierarchyModule,
|
||||
EnrollmentModule,
|
||||
...(federationEnabled ? [FederationModule] : []),
|
||||
],
|
||||
controllers: [HealthController],
|
||||
|
||||
@@ -0,0 +1,508 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
import { Logger, ValidationPipe, type ExecutionContext } from '@nestjs/common';
|
||||
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||
import supertest from 'supertest';
|
||||
import { unseal } from '@mosaicstack/auth';
|
||||
import {
|
||||
agentAuditEvents,
|
||||
agentIdempotencyFence,
|
||||
agentOutbox,
|
||||
agents,
|
||||
and,
|
||||
createPgliteDb,
|
||||
eq,
|
||||
providerCredentials,
|
||||
runPgliteMigrations,
|
||||
sql,
|
||||
users,
|
||||
type DbHandle,
|
||||
} from '@mosaicstack/db';
|
||||
import { DB } from '../database/database.module.js';
|
||||
import { AuthGuard } from '../auth/auth.guard.js';
|
||||
import { HarnessRegistry } from '../harness/harness.registry.js';
|
||||
import { HARNESS_REGISTRY } from '../harness/harness.tokens.js';
|
||||
import { FakeHarnessAdapter } from '../harness/testing/fake-harness.adapter.js';
|
||||
import { EnrollmentController } from './enrollment.controller.js';
|
||||
import {
|
||||
EnrollmentRepository,
|
||||
type EnrollAgentInput,
|
||||
type EnrollmentResult,
|
||||
type EnrolledAgentView,
|
||||
} from './enrollment.repository.js';
|
||||
import { EnrollmentService } from './enrollment.service.js';
|
||||
|
||||
/**
|
||||
* Command-level witnesses for the agent enrollment family (M4-4b) — design
|
||||
* docs/plans/2026-08-29-agent-enrollment-command-design.md §5 items 1–9 and
|
||||
* 11 (item 10, CLI parity, lives in packages/mosaic). Schema-level
|
||||
* constraints are witnessed in packages/db/src/agent-enrollment.witness.test.ts.
|
||||
*
|
||||
* The suite runs the REAL repository/service/controller graph over PGlite,
|
||||
* with only AuthGuard overridden (a session store is out of scope; the
|
||||
* override binds request.user exactly as the real guard does). The §6.3
|
||||
* static companions — no `any`-typed boundary pass-through, a single audit
|
||||
* emitter (EnrollmentRepository.appendEvent) — are code-surface properties
|
||||
* reviewed on the PR, not runtime probes.
|
||||
*/
|
||||
describe('enrollment commands integration', (): void => {
|
||||
let dataDir: string;
|
||||
let handle: DbHandle;
|
||||
let moduleRef: TestingModule;
|
||||
let app: NestFastifyApplication;
|
||||
let http: ReturnType<typeof supertest>;
|
||||
let repo: EnrollmentRepository;
|
||||
let previousAuthSecret: string | undefined;
|
||||
|
||||
const OWNER = 'enr-owner';
|
||||
const ADMIN = 'enr-admin';
|
||||
const STRANGER = 'enr-stranger';
|
||||
const HARNESS = 'fake-harness';
|
||||
/** Never-echo probe value (§5.1). Unique enough that any leak is unambiguous. */
|
||||
const SECRET = `enr-secret-value-${randomUUID()}`;
|
||||
|
||||
/** The HTTP-leg acting user; the overridden guard binds it per request. */
|
||||
let currentUserId = OWNER;
|
||||
|
||||
const enrollInput = (overrides: Partial<EnrollAgentInput> = {}): EnrollAgentInput => ({
|
||||
actorId: OWNER,
|
||||
harness: HARNESS,
|
||||
name: `Agent ${randomUUID().slice(0, 8)}`,
|
||||
persona: null,
|
||||
model: 'anthropic/claude-test',
|
||||
provider: `prov-${randomUUID().slice(0, 8)}`,
|
||||
credential: { mode: 'intake', type: 'api_key', value: SECRET },
|
||||
idempotencyKey: randomUUID(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
function expectOk<T>(result: EnrollmentResult<T>): { ok: true; correlationId: string } & T {
|
||||
if (!result.ok) throw new Error(`expected ok, got ${JSON.stringify(result)}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
function expectFail<T>(
|
||||
result: EnrollmentResult<T>,
|
||||
error: string,
|
||||
): { ok: false; error: string; message: string; correlationId: string } {
|
||||
if (result.ok) throw new Error(`expected ${error}, got ok`);
|
||||
expect(result.error).toBe(error);
|
||||
return result;
|
||||
}
|
||||
|
||||
const fenceForKey = (key: string) =>
|
||||
handle.db
|
||||
.select()
|
||||
.from(agentIdempotencyFence)
|
||||
.where(eq(agentIdempotencyFence.idempotencyKey, key));
|
||||
|
||||
const eventsForAgent = (agentId: string) =>
|
||||
handle.db.select().from(agentAuditEvents).where(eq(agentAuditEvents.agentId, agentId));
|
||||
|
||||
const agentsNamed = (name: string) =>
|
||||
handle.db.select().from(agents).where(eq(agents.name, name));
|
||||
|
||||
const credentialsFor = (userId: string, provider: string) =>
|
||||
handle.db
|
||||
.select()
|
||||
.from(providerCredentials)
|
||||
.where(
|
||||
and(eq(providerCredentials.userId, userId), eq(providerCredentials.provider, provider)),
|
||||
);
|
||||
|
||||
const allOutbox = () => handle.db.select().from(agentOutbox);
|
||||
|
||||
beforeAll(async (): Promise<void> => {
|
||||
previousAuthSecret = process.env['BETTER_AUTH_SECRET'];
|
||||
process.env['BETTER_AUTH_SECRET'] = 'enrollment-witness-sealing-key';
|
||||
|
||||
dataDir = await mkdtemp(join(tmpdir(), 'mosaic-gateway-enrollment-commands-'));
|
||||
handle = createPgliteDb(dataDir);
|
||||
await runPgliteMigrations(handle);
|
||||
|
||||
const registry = new HarnessRegistry();
|
||||
registry.register(new FakeHarnessAdapter({ id: HARNESS }));
|
||||
|
||||
moduleRef = await Test.createTestingModule({
|
||||
controllers: [EnrollmentController],
|
||||
providers: [
|
||||
EnrollmentRepository,
|
||||
EnrollmentService,
|
||||
{ provide: DB, useValue: handle.db },
|
||||
{ provide: HARNESS_REGISTRY, useValue: registry },
|
||||
],
|
||||
})
|
||||
.overrideGuard(AuthGuard)
|
||||
.useValue({
|
||||
canActivate: (ctx: ExecutionContext): boolean => {
|
||||
const request = ctx.switchToHttp().getRequest<{ user?: unknown }>();
|
||||
request.user = { id: currentUserId };
|
||||
return true;
|
||||
},
|
||||
})
|
||||
.compile();
|
||||
|
||||
app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
|
||||
// Mirror main.ts exactly — the closure witnesses depend on these options.
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }),
|
||||
);
|
||||
await app.init();
|
||||
await app.getHttpAdapter().getInstance().ready();
|
||||
http = supertest(app.getHttpServer());
|
||||
repo = moduleRef.get(EnrollmentRepository);
|
||||
|
||||
await handle.db.insert(users).values([
|
||||
{ id: OWNER, name: 'Owner', email: `${OWNER}@example.com` },
|
||||
{ id: ADMIN, name: 'Admin', email: `${ADMIN}@example.com`, role: 'admin' },
|
||||
{ id: STRANGER, name: 'Stranger', email: `${STRANGER}@example.com` },
|
||||
]);
|
||||
});
|
||||
|
||||
afterAll(async (): Promise<void> => {
|
||||
await app?.close();
|
||||
await handle.close();
|
||||
await rm(dataDir, { recursive: true, force: true });
|
||||
if (previousAuthSecret === undefined) delete process.env['BETTER_AUTH_SECRET'];
|
||||
else process.env['BETTER_AUTH_SECRET'] = previousAuthSecret;
|
||||
});
|
||||
|
||||
// ── §5.7 wizard-facing zero-mutation (runs FIRST: no call → zero rows) ────
|
||||
|
||||
it('zero-mutation: with no enrollment invocation the family tables hold zero rows', async () => {
|
||||
expect(await handle.db.select().from(agents)).toHaveLength(0);
|
||||
expect(await handle.db.select().from(agentAuditEvents)).toHaveLength(0);
|
||||
expect(await handle.db.select().from(agentOutbox)).toHaveLength(0);
|
||||
expect(await handle.db.select().from(agentIdempotencyFence)).toHaveLength(0);
|
||||
});
|
||||
|
||||
// ── §5.1 never-echo + §5.2 sealed single-copy ─────────────────────────────
|
||||
|
||||
it('never echoes the intake credential value: HTTP result, audit, outbox, fence, and logs are clean', async () => {
|
||||
const logSink: string[] = [];
|
||||
const logSpies = (['log', 'error', 'warn', 'debug', 'verbose'] as const).map((method) =>
|
||||
vi.spyOn(Logger.prototype, method).mockImplementation((...args: unknown[]) => {
|
||||
logSink.push(args.map(String).join(' '));
|
||||
}),
|
||||
);
|
||||
try {
|
||||
currentUserId = OWNER;
|
||||
const provider = `prov-echo-${randomUUID().slice(0, 8)}`;
|
||||
const res = await http.post('/api/enrollment/agents').send({
|
||||
harness: HARNESS,
|
||||
name: 'Echo Probe',
|
||||
persona: 'a persona',
|
||||
model: 'anthropic/claude-test',
|
||||
provider,
|
||||
credential: { mode: 'intake', type: 'api_key', value: SECRET },
|
||||
idempotencyKey: randomUUID(),
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.text).not.toContain(SECRET);
|
||||
const agentId = (res.body as { agent: EnrolledAgentView }).agent.id;
|
||||
|
||||
const events = await eventsForAgent(agentId);
|
||||
expect(events).toHaveLength(1);
|
||||
expect(JSON.stringify(events)).not.toContain(SECRET);
|
||||
expect(JSON.stringify(await allOutbox())).not.toContain(SECRET);
|
||||
const fences = await handle.db
|
||||
.select()
|
||||
.from(agentIdempotencyFence)
|
||||
.where(eq(agentIdempotencyFence.outcomeAgentId, agentId));
|
||||
expect(fences).toHaveLength(1);
|
||||
expect(JSON.stringify(fences)).not.toContain(SECRET);
|
||||
expect(logSink.join('\n')).not.toContain(SECRET);
|
||||
|
||||
// §5.2 sealed single-copy: exactly one provider_credentials row, sealed
|
||||
// at rest, and it round-trips through unseal — no plaintext column.
|
||||
const creds = await credentialsFor(OWNER, provider);
|
||||
expect(creds).toHaveLength(1);
|
||||
expect(creds[0]?.encryptedValue).not.toBe(SECRET);
|
||||
expect(creds[0]?.encryptedValue).not.toContain(SECRET);
|
||||
expect(unseal(creds[0]?.encryptedValue as string)).toBe(SECRET);
|
||||
} finally {
|
||||
logSpies.forEach((spy) => spy.mockRestore());
|
||||
}
|
||||
});
|
||||
|
||||
it('the agents table itself has no credential-bearing column (§5.2)', async () => {
|
||||
const result = (await handle.db.execute(
|
||||
sql`select column_name from information_schema.columns where table_name = 'agents'`,
|
||||
)) as unknown as { rows?: Array<{ column_name: string }> } & Array<{ column_name: string }>;
|
||||
const names = (result.rows ?? result).map((row) => row.column_name);
|
||||
expect(names.length).toBeGreaterThan(0);
|
||||
for (const name of names) {
|
||||
expect(name).not.toMatch(/credential|secret|token|api_key/i);
|
||||
}
|
||||
});
|
||||
|
||||
// ── §5.3 reference resolution ─────────────────────────────────────────────
|
||||
|
||||
it('refuses an unresolvable credential reference with precondition_failed and creates nothing', async () => {
|
||||
const input = enrollInput({ credential: { mode: 'reference' } });
|
||||
const result = await repo.enroll(input);
|
||||
expectFail(result, 'precondition_failed');
|
||||
expect(await agentsNamed(input.name)).toHaveLength(0);
|
||||
expect(await fenceForKey(input.idempotencyKey)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('resolves a reference credential stored earlier for (actor, provider)', async () => {
|
||||
const provider = `prov-ref-${randomUUID().slice(0, 8)}`;
|
||||
const seeded = expectOk(await repo.enroll(enrollInput({ provider })));
|
||||
const result = expectOk(
|
||||
await repo.enroll(enrollInput({ provider, credential: { mode: 'reference' } })),
|
||||
);
|
||||
expect(result.agent.id).not.toBe(seeded.agent.id);
|
||||
expect(await credentialsFor(OWNER, provider)).toHaveLength(1);
|
||||
});
|
||||
|
||||
// ── §5.4 harness refusals, both codes ────────────────────────────────────
|
||||
|
||||
it('refuses a syntactically invalid harness as validation_failed and a registry miss as precondition_failed', async () => {
|
||||
const blank = await repo.enroll(enrollInput({ harness: ' ' }));
|
||||
expectFail(blank, 'validation_failed');
|
||||
const miss = await repo.enroll(enrollInput({ harness: 'well-formed-but-unregistered' }));
|
||||
expectFail(miss, 'precondition_failed');
|
||||
|
||||
currentUserId = OWNER;
|
||||
const httpBlank = await http.post('/api/enrollment/agents').send({
|
||||
harness: '',
|
||||
name: 'H',
|
||||
model: 'm',
|
||||
provider: 'p',
|
||||
credential: { mode: 'reference' },
|
||||
idempotencyKey: randomUUID(),
|
||||
});
|
||||
expect(httpBlank.status).toBe(400);
|
||||
});
|
||||
|
||||
// ── §5.5 idempotency set (contract 3 §4.3) ───────────────────────────────
|
||||
|
||||
it('actor-bound replay returns the recorded outcome and executes nothing new', async () => {
|
||||
const input = enrollInput();
|
||||
const first = expectOk(await repo.enroll(input));
|
||||
const replay = expectOk(await repo.enroll({ ...input, correlationId: randomUUID() }));
|
||||
expect(replay.agent.id).toBe(first.agent.id);
|
||||
|
||||
expect(await agentsNamed(input.name)).toHaveLength(1);
|
||||
expect(await fenceForKey(input.idempotencyKey)).toHaveLength(1);
|
||||
const events = await eventsForAgent(first.agent.id);
|
||||
expect(events.filter((e) => e.eventType === 'agent.enrolled')).toHaveLength(1);
|
||||
// A passing replay appends exactly the non-mutation access event.
|
||||
const replayed = events.filter((e) => e.eventType === 'agent.enrollment.replayed');
|
||||
expect(replayed).toHaveLength(1);
|
||||
expect((replayed[0]?.payload as { fenceId?: string }).fenceId).toBeDefined();
|
||||
});
|
||||
|
||||
it('payload-digest mismatch on a recorded key refuses with the single bounded conflict shape', async () => {
|
||||
const input = enrollInput();
|
||||
expectOk(await repo.enroll(input));
|
||||
const mismatch = await repo.enroll({ ...input, name: `${input.name} CHANGED` });
|
||||
const failure = expectFail(mismatch, 'conflict');
|
||||
expect(failure.message).toBe('idempotency conflict');
|
||||
});
|
||||
|
||||
it('replay-mode and scope mismatches on the recorded fence each refuse as the same constant conflict', async () => {
|
||||
const modeInput = enrollInput();
|
||||
expectOk(await repo.enroll(modeInput));
|
||||
await handle.db
|
||||
.update(agentIdempotencyFence)
|
||||
.set({ replayMode: 'shared' })
|
||||
.where(eq(agentIdempotencyFence.idempotencyKey, modeInput.idempotencyKey));
|
||||
const modeFailure = expectFail(await repo.enroll(modeInput), 'conflict');
|
||||
|
||||
const scopeInput = enrollInput();
|
||||
expectOk(await repo.enroll(scopeInput));
|
||||
await handle.db
|
||||
.update(agentIdempotencyFence)
|
||||
.set({ authorizationScope: 'some-other-scope' })
|
||||
.where(eq(agentIdempotencyFence.idempotencyKey, scopeInput.idempotencyKey));
|
||||
const scopeFailure = expectFail(await repo.enroll(scopeInput), 'conflict');
|
||||
|
||||
expect(modeFailure.message).toBe(scopeFailure.message);
|
||||
});
|
||||
|
||||
it('a different actor replaying an actor-bound key is refused conflict, learning nothing', async () => {
|
||||
const input = enrollInput();
|
||||
expectOk(await repo.enroll(input));
|
||||
const failure = expectFail(await repo.enroll({ ...input, actorId: STRANGER }), 'conflict');
|
||||
expect(failure.message).toBe('idempotency conflict');
|
||||
});
|
||||
|
||||
it('a replay is re-authorized fresh: revoked target authority refuses instead of replaying', async () => {
|
||||
const input = enrollInput();
|
||||
const first = expectOk(await repo.enroll(input));
|
||||
// Simulate the legacy CRUD DELETE path removing the outcome agent: the
|
||||
// submitter no longer holds read authority on the referenced row.
|
||||
await handle.db.delete(agents).where(eq(agents.id, first.agent.id));
|
||||
expectFail(await repo.enroll(input), 'conflict');
|
||||
});
|
||||
|
||||
it('a shared replay-mode declaration is refused validation_failed with nothing executed and no fence row', async () => {
|
||||
const input = enrollInput({ replayMode: 'shared' });
|
||||
expectFail(await repo.enroll(input), 'validation_failed');
|
||||
expect(await agentsNamed(input.name)).toHaveLength(0);
|
||||
expect(await fenceForKey(input.idempotencyKey)).toHaveLength(0);
|
||||
|
||||
currentUserId = OWNER;
|
||||
const key = randomUUID();
|
||||
const res = await http.post('/api/enrollment/agents').send({
|
||||
harness: HARNESS,
|
||||
name: 'Shared Probe',
|
||||
model: 'm',
|
||||
provider: 'p',
|
||||
credential: { mode: 'reference' },
|
||||
idempotencyKey: key,
|
||||
replayMode: 'shared',
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
expect(await fenceForKey(key)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('two concurrent same-key submissions produce exactly one mutation, the loser resolving as a replay', async () => {
|
||||
const input = enrollInput();
|
||||
const [a, b] = await Promise.all([
|
||||
repo.enroll(input),
|
||||
repo.enroll({ ...input, correlationId: randomUUID() }),
|
||||
]);
|
||||
const okA = expectOk(a);
|
||||
const okB = expectOk(b);
|
||||
expect(okA.agent.id).toBe(okB.agent.id);
|
||||
expect(await agentsNamed(input.name)).toHaveLength(1);
|
||||
expect(await fenceForKey(input.idempotencyKey)).toHaveLength(1);
|
||||
const events = await eventsForAgent(okA.agent.id);
|
||||
expect(events.filter((e) => e.eventType === 'agent.enrolled')).toHaveLength(1);
|
||||
expect(events.filter((e) => e.eventType === 'agent.enrollment.replayed')).toHaveLength(1);
|
||||
});
|
||||
|
||||
// ── §5.6 same-tx atomicity fault injection ───────────────────────────────
|
||||
|
||||
it('rolls everything back on failure at each write point — no orphan credential survives', async () => {
|
||||
const injectionPoints = [
|
||||
'writeSealedCredential',
|
||||
'insertAgentRow',
|
||||
'insertFenceRow',
|
||||
'appendEvent',
|
||||
'insertOutboxRow',
|
||||
] as const;
|
||||
|
||||
for (const point of injectionPoints) {
|
||||
const input = enrollInput();
|
||||
const spy = vi.spyOn(repo, point).mockImplementationOnce(() => {
|
||||
throw new Error(`injected ${point} fault`);
|
||||
});
|
||||
try {
|
||||
const result = await repo.enroll(input);
|
||||
expectFail(result, 'internal_fault');
|
||||
expect(await agentsNamed(input.name)).toHaveLength(0);
|
||||
expect(await fenceForKey(input.idempotencyKey)).toHaveLength(0);
|
||||
// Injection at fence/audit/outbox fires AFTER the sealed credential
|
||||
// write's statement ran — the rollback must leave no orphan row.
|
||||
expect(await credentialsFor(OWNER, input.provider)).toHaveLength(0);
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ── §5.8 is_system closure ───────────────────────────────────────────────
|
||||
|
||||
it('rejects an is_system injection attempt at the DTO boundary', async () => {
|
||||
currentUserId = OWNER;
|
||||
const key = randomUUID();
|
||||
const res = await http.post('/api/enrollment/agents').send({
|
||||
harness: HARNESS,
|
||||
name: 'System Probe',
|
||||
model: 'm',
|
||||
provider: 'p',
|
||||
credential: { mode: 'reference' },
|
||||
idempotencyKey: key,
|
||||
isSystem: true,
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
expect(await fenceForKey(key)).toHaveLength(0);
|
||||
});
|
||||
|
||||
// ── §5.9 correlation + no-existence-oracle ───────────────────────────────
|
||||
|
||||
it('carries a submitted correlation id into the result, the audit event, and the outbox record', async () => {
|
||||
const correlationId = randomUUID();
|
||||
const input = enrollInput({ correlationId });
|
||||
const result = expectOk(await repo.enroll(input));
|
||||
expect(result.correlationId).toBe(correlationId);
|
||||
const events = await eventsForAgent(result.agent.id);
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]?.correlationId).toBe(correlationId);
|
||||
const outboxRows = await handle.db
|
||||
.select()
|
||||
.from(agentOutbox)
|
||||
.where(eq(agentOutbox.eventId, events[0]?.id as string));
|
||||
expect(outboxRows).toHaveLength(1);
|
||||
expect(outboxRows[0]?.correlationId).toBe(correlationId);
|
||||
|
||||
// Refusals carry the correlation envelope too (contract 5 §4.3).
|
||||
const refusal = expectFail(
|
||||
await repo.enroll({ ...input, name: 'changed name', correlationId }),
|
||||
'conflict',
|
||||
);
|
||||
expect(refusal.correlationId).toBe(correlationId);
|
||||
});
|
||||
|
||||
it('agent.enrollment.get returns owner and admin reads with the correlation envelope, no idempotency key', async () => {
|
||||
const enrolled = expectOk(await repo.enroll(enrollInput()));
|
||||
const correlationId = randomUUID();
|
||||
const asOwner = expectOk(await repo.getEnrollment(OWNER, enrolled.agent.id, correlationId));
|
||||
expect(asOwner.correlationId).toBe(correlationId);
|
||||
expect(asOwner.agent.id).toBe(enrolled.agent.id);
|
||||
const asAdmin = expectOk(await repo.getEnrollment(ADMIN, enrolled.agent.id));
|
||||
expect(asAdmin.correlationId).toMatch(/^[0-9a-f-]{36}$/);
|
||||
|
||||
currentUserId = OWNER;
|
||||
const wire = randomUUID();
|
||||
const res = await http.get(`/api/enrollment/agents/${enrolled.agent.id}?correlationId=${wire}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect((res.body as { correlationId: string }).correlationId).toBe(wire);
|
||||
});
|
||||
|
||||
it('no existence oracle: unauthorized get of a real agent and get of a missing id are indistinguishable', async () => {
|
||||
const enrolled = expectOk(await repo.enroll(enrollInput()));
|
||||
|
||||
currentUserId = STRANGER;
|
||||
const unauthorized = await http.get(`/api/enrollment/agents/${enrolled.agent.id}`);
|
||||
const missing = await http.get(`/api/enrollment/agents/${randomUUID()}`);
|
||||
expect(unauthorized.status).toBe(404);
|
||||
expect(missing.status).toBe(404);
|
||||
const strip = (body: Record<string, unknown>): Record<string, unknown> =>
|
||||
Object.fromEntries(Object.entries(body).filter(([key]) => key !== 'correlationId'));
|
||||
expect(strip(unauthorized.body as Record<string, unknown>)).toEqual(
|
||||
strip(missing.body as Record<string, unknown>),
|
||||
);
|
||||
});
|
||||
|
||||
// ── §5.11 fail-closed ────────────────────────────────────────────────────
|
||||
|
||||
it('fails closed as internal_fault when the store is unreachable, with no fallback write', async () => {
|
||||
const before = (await handle.db.select().from(agents)).length;
|
||||
const txSpy = vi.spyOn(handle.db, 'transaction').mockImplementationOnce(() => {
|
||||
throw new Error('injected store outage');
|
||||
});
|
||||
try {
|
||||
expectFail(await repo.enroll(enrollInput()), 'internal_fault');
|
||||
} finally {
|
||||
txSpy.mockRestore();
|
||||
}
|
||||
const selectSpy = vi.spyOn(handle.db, 'select').mockImplementationOnce(() => {
|
||||
throw new Error('injected store outage');
|
||||
});
|
||||
try {
|
||||
expectFail(await repo.getEnrollment(OWNER, randomUUID()), 'internal_fault');
|
||||
} finally {
|
||||
selectSpy.mockRestore();
|
||||
}
|
||||
expect((await handle.db.select().from(agents)).length).toBe(before);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard } from '../auth/auth.guard.js';
|
||||
import { CurrentUser } from '../auth/current-user.decorator.js';
|
||||
import { EnrollAgentDto, GetEnrollmentQueryDto } from './enrollment.dto.js';
|
||||
import { EnrollmentRepository } from './enrollment.repository.js';
|
||||
import { EnrollmentService } from './enrollment.service.js';
|
||||
|
||||
/**
|
||||
* The agent enrollment command family's closed HTTP surface (design
|
||||
* docs/plans/2026-08-29-agent-enrollment-command-design.md §3): one command,
|
||||
* one query. Authentication failures are the guard's (401); everything else
|
||||
* is the repository's closed enum mapped by EnrollmentService.
|
||||
*/
|
||||
@Controller('api/enrollment')
|
||||
@UseGuards(AuthGuard)
|
||||
export class EnrollmentController {
|
||||
constructor(
|
||||
private readonly repository: EnrollmentRepository,
|
||||
private readonly service: EnrollmentService,
|
||||
) {}
|
||||
|
||||
/** agent.enroll (§3.1). */
|
||||
@Post('agents')
|
||||
async enroll(@CurrentUser() user: { id: string }, @Body() dto: EnrollAgentDto) {
|
||||
return this.service.unwrap(
|
||||
await this.repository.enroll({
|
||||
actorId: user.id,
|
||||
harness: dto.harness,
|
||||
name: dto.name,
|
||||
persona: dto.persona ?? null,
|
||||
model: dto.model,
|
||||
provider: dto.provider,
|
||||
credential: dto.credential,
|
||||
idempotencyKey: dto.idempotencyKey,
|
||||
correlationId: dto.correlationId,
|
||||
replayMode: dto.replayMode,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** agent.enrollment.get (§3.2): owner-or-admin; unauthorized and missing fold to one not_found. */
|
||||
@Get('agents/:id')
|
||||
async getEnrollment(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Query() query: GetEnrollmentQueryDto,
|
||||
) {
|
||||
return this.service.unwrap(
|
||||
await this.repository.getEnrollment(user.id, id, query.correlationId),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsIn,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
MaxLength,
|
||||
MinLength,
|
||||
ValidateIf,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
/**
|
||||
* Agent enrollment command DTOs (design
|
||||
* docs/plans/2026-08-29-agent-enrollment-command-design.md §3.1/§3.2,
|
||||
* contract 5 §4.1 typed boundary).
|
||||
*
|
||||
* The global ValidationPipe runs with whitelist + forbidNonWhitelisted, so
|
||||
* closure is contract surface here exactly as in the hierarchy DTOs:
|
||||
* - EnrollAgentDto declares NO isSystem field — `is_system` is never
|
||||
* settable through this command (design §3.1 rule 4); the pipe refuses it.
|
||||
* - replayMode admits ONLY 'actor-bound': `shared` is seed-only (contract 3
|
||||
* §4.3), so a shared declaration is refused `validation_failed` at the
|
||||
* boundary, executes nothing, and records no fence row (design §3.1).
|
||||
* Every class here must be registered in PIPE_GUARDED_DTOS so the boot-time
|
||||
* assertion proves the pipe sees the decorators.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Credential input, discriminated on `mode` (design §3.1):
|
||||
* - `{ mode: 'reference' }` — a stored credential for (actor, provider)
|
||||
* must already exist; `type`/`value` must be ABSENT (the repository
|
||||
* refuses a reference that smuggles a value).
|
||||
* - `{ mode: 'intake', type: 'api_key', value }` — the value is sealed
|
||||
* into the credential store inside the enrollment transaction and is
|
||||
* never echoed anywhere (§3.1 rule 1).
|
||||
*/
|
||||
export class EnrollCredentialDto {
|
||||
@IsIn(['reference', 'intake'])
|
||||
mode!: 'reference' | 'intake';
|
||||
|
||||
@ValidateIf((o: EnrollCredentialDto) => o.mode === 'intake')
|
||||
@IsIn(['api_key'])
|
||||
type?: 'api_key';
|
||||
|
||||
@ValidateIf((o: EnrollCredentialDto) => o.mode === 'intake')
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(4096)
|
||||
value?: string;
|
||||
}
|
||||
|
||||
export class EnrollAgentDto {
|
||||
/** Registered harness name; a well-formed name missing from the registry is `precondition_failed`. */
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(200)
|
||||
harness!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(200)
|
||||
name!: string;
|
||||
|
||||
/** Stored as the agent's system prompt; null/absent leaves it unset. */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(20000)
|
||||
persona?: string | null;
|
||||
|
||||
/** Provider-qualified model id. */
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(200)
|
||||
model!: string;
|
||||
|
||||
/** Names the credential's provider. */
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(200)
|
||||
provider!: string;
|
||||
|
||||
@ValidateNested()
|
||||
@Type(() => EnrollCredentialDto)
|
||||
credential!: EnrollCredentialDto;
|
||||
|
||||
/** REQUIRED — contract 3 §4.3, ratified into contract 5 §4 via §7 item 4. */
|
||||
@IsUUID()
|
||||
idempotencyKey!: string;
|
||||
|
||||
/** Optional; generated when absent (contract 5 §4.3). */
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
correlationId?: string;
|
||||
|
||||
/** Only 'actor-bound' is admissible on this family — see module doc. */
|
||||
@IsOptional()
|
||||
@IsIn(['actor-bound'])
|
||||
replayMode?: 'actor-bound';
|
||||
}
|
||||
|
||||
/** Query envelope for agent.enrollment.get (design §3.2): correlation only, no idempotency key. */
|
||||
export class GetEnrollmentQueryDto {
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
correlationId?: string;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HarnessModule } from '../harness/harness.module.js';
|
||||
import { EnrollmentController } from './enrollment.controller.js';
|
||||
import { EnrollmentRepository } from './enrollment.repository.js';
|
||||
import { EnrollmentService } from './enrollment.service.js';
|
||||
|
||||
/**
|
||||
* Agent enrollment command family (M4-4b; design
|
||||
* docs/plans/2026-08-29-agent-enrollment-command-design.md). Imports
|
||||
* HarnessModule for the live harness registry — the validation source for
|
||||
* the `harness` field (a well-formed name the registry does not know is a
|
||||
* precondition failure). EnrollmentRepository is the family's sole writer;
|
||||
* every mutation runs fence-check → mutate → audit + outbox in one
|
||||
* transaction.
|
||||
*/
|
||||
@Module({
|
||||
imports: [HarnessModule],
|
||||
controllers: [EnrollmentController],
|
||||
providers: [EnrollmentRepository, EnrollmentService],
|
||||
exports: [EnrollmentRepository],
|
||||
})
|
||||
export class EnrollmentModule {}
|
||||
@@ -0,0 +1,538 @@
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { seal } from '@mosaicstack/auth';
|
||||
import {
|
||||
agentAuditEvents,
|
||||
agentIdempotencyFence,
|
||||
agentOutbox,
|
||||
agents,
|
||||
and,
|
||||
eq,
|
||||
providerCredentials,
|
||||
users,
|
||||
type Db,
|
||||
} from '@mosaicstack/db';
|
||||
import { DB } from '../database/database.module.js';
|
||||
import type { HarnessRegistry } from '../harness/harness.registry.js';
|
||||
import { HARNESS_REGISTRY } from '../harness/harness.tokens.js';
|
||||
|
||||
/**
|
||||
* Agent enrollment command repository (design
|
||||
* docs/plans/2026-08-29-agent-enrollment-command-design.md §3; contract 5 §4
|
||||
* envelope; contract 3 §4.3 idempotency fence, ratified via §7 item 4).
|
||||
*
|
||||
* The ONLY writer of the enrollment family's tables (`agent_audit_events`,
|
||||
* `agent_outbox`, `agent_idempotency_fence`) and the only path that sets
|
||||
* `agents.harness`/`agents.enrolled_at`. Every enroll runs one transaction:
|
||||
* fence check → (replay | credential handling → agent insert → fence insert →
|
||||
* audit event + outbox), so state, fence, event, and outbox commit or roll
|
||||
* back together (§3.1 rule 6).
|
||||
*
|
||||
* Authorization (v1, §3.1 rule 4) is the AuthGuard-authenticated actor — no
|
||||
* hierarchy grant is consulted because v1 enrollment binds no hierarchy node.
|
||||
* The recorded fence authorization scope is therefore the constant
|
||||
* platform-user identity domain (§3.1 rule 5).
|
||||
*
|
||||
* Never-echo (§3.1 rule 1): the credential value reaches exactly one sink —
|
||||
* the sealed store write — and appears in no result, audit payload, outbox
|
||||
* row, or log line. Log lines here carry correlation ids and error names
|
||||
* only, never request fields.
|
||||
*
|
||||
* The single-write helper methods (writeSealedCredential, insertAgentRow,
|
||||
* insertFenceRow, appendEvent, insertOutboxRow) are ordinary decomposition;
|
||||
* the atomicity witnesses (§5.6) spy on them to inject faults at each write
|
||||
* point without any test-only production switch.
|
||||
*/
|
||||
|
||||
export const ENROLLMENT_OPERATION = 'agent.enroll';
|
||||
/** §3.1 rule 5: v1 authorization is grant-free, so the scope is the authenticated-user identity domain. */
|
||||
const AUTHORIZATION_SCOPE = 'platform-user';
|
||||
/** The single bounded collision shape (§3.1 rule 5): constant, identifying no record. */
|
||||
const CONFLICT_MESSAGE = 'idempotency conflict';
|
||||
/** One fixed message for every not_found cause — missing and unauthorized are indistinguishable (§3.2). */
|
||||
const NOT_FOUND_MESSAGE = 'agent not found';
|
||||
|
||||
/** Closed per-family error enum (§3.3). 401 is produced by AuthGuard; 403 folds to not_found (§3.2). */
|
||||
export type EnrollmentErrorCode =
|
||||
| 'validation_failed'
|
||||
| 'authentication_failed'
|
||||
| 'authorization_refused'
|
||||
| 'not_found'
|
||||
| 'conflict'
|
||||
| 'precondition_failed'
|
||||
| 'internal_fault';
|
||||
|
||||
export interface EnrollmentFailure {
|
||||
readonly ok: false;
|
||||
readonly error: EnrollmentErrorCode;
|
||||
readonly message: string;
|
||||
/** Refusals carry the correlation id too (contract 5 §4.3 end-to-end traceability). */
|
||||
readonly correlationId: string;
|
||||
}
|
||||
|
||||
export type EnrollmentResult<T> =
|
||||
| ({ readonly ok: true; readonly correlationId: string } & T)
|
||||
| EnrollmentFailure;
|
||||
|
||||
/** The persisted agent row; the table stores no credential material (§3.1 rule 1). */
|
||||
export interface EnrolledAgentView {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly provider: string;
|
||||
readonly model: string;
|
||||
readonly status: string;
|
||||
readonly harness: string | null;
|
||||
readonly persona: string | null;
|
||||
readonly ownerId: string | null;
|
||||
readonly enrolledAt: string | null;
|
||||
readonly createdAt: string;
|
||||
}
|
||||
|
||||
export interface EnrollCredentialInput {
|
||||
readonly mode: 'reference' | 'intake';
|
||||
readonly type?: 'api_key';
|
||||
readonly value?: string;
|
||||
}
|
||||
|
||||
export interface EnrollAgentInput {
|
||||
readonly actorId: string;
|
||||
readonly harness: string;
|
||||
readonly name: string;
|
||||
readonly persona?: string | null;
|
||||
readonly model: string;
|
||||
readonly provider: string;
|
||||
readonly credential: EnrollCredentialInput;
|
||||
readonly idempotencyKey: string;
|
||||
readonly correlationId?: string;
|
||||
/** Defense in depth below the DTO: anything but 'actor-bound' is refused (seed-only rule). */
|
||||
readonly replayMode?: string;
|
||||
}
|
||||
|
||||
type Tx = Pick<Db, 'insert' | 'select' | 'update' | 'delete'>;
|
||||
type AgentRow = typeof agents.$inferSelect;
|
||||
type FenceRow = typeof agentIdempotencyFence.$inferSelect;
|
||||
|
||||
/** Raised inside the transaction when the fence insert lost a same-key race (§3.1 rule 5 concurrency). */
|
||||
class ConcurrentEnrollmentError extends Error {
|
||||
constructor() {
|
||||
super('concurrent enrollment lost the fence race');
|
||||
this.name = 'ConcurrentEnrollmentError';
|
||||
}
|
||||
}
|
||||
|
||||
function agentView(row: AgentRow): EnrolledAgentView {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
provider: row.provider,
|
||||
model: row.model,
|
||||
status: row.status,
|
||||
harness: row.harness,
|
||||
persona: row.systemPrompt,
|
||||
ownerId: row.ownerId,
|
||||
enrolledAt: row.enrolledAt?.toISOString() ?? null,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Key-order-independent serialization (jsonb precedent in hierarchy-audit). */
|
||||
function canonicalJson(value: unknown): string {
|
||||
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
|
||||
if (value !== null && typeof value === 'object') {
|
||||
const record = value as Record<string, unknown>;
|
||||
const body = Object.keys(record)
|
||||
.sort()
|
||||
.map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`)
|
||||
.join(',');
|
||||
return `{${body}}`;
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
interface NormalizedEnrollment {
|
||||
readonly actorId: string;
|
||||
readonly harness: string;
|
||||
readonly name: string;
|
||||
readonly persona: string | null;
|
||||
readonly model: string;
|
||||
readonly provider: string;
|
||||
readonly credential: EnrollCredentialInput;
|
||||
readonly idempotencyKey: string;
|
||||
readonly correlationId: string;
|
||||
readonly digest: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonicalized-payload digest (§3.1 rule 5). The input EXCLUDES the
|
||||
* credential value by construction: it covers mode and declared type only —
|
||||
* plaintext never reaches the hash.
|
||||
*/
|
||||
function digestOf(
|
||||
input: Omit<NormalizedEnrollment, 'actorId' | 'idempotencyKey' | 'correlationId' | 'digest'>,
|
||||
): string {
|
||||
const canonical = canonicalJson({
|
||||
harness: input.harness,
|
||||
name: input.name,
|
||||
persona: input.persona,
|
||||
model: input.model,
|
||||
provider: input.provider,
|
||||
credential: { mode: input.credential.mode, type: input.credential.type ?? null },
|
||||
});
|
||||
return createHash('sha256').update(canonical).digest('hex');
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class EnrollmentRepository {
|
||||
private readonly logger = new Logger(EnrollmentRepository.name);
|
||||
|
||||
constructor(
|
||||
@Inject(DB) private readonly db: Db,
|
||||
@Inject(HARNESS_REGISTRY) private readonly registry: HarnessRegistry,
|
||||
) {}
|
||||
|
||||
async enroll(input: EnrollAgentInput): Promise<EnrollmentResult<{ agent: EnrolledAgentView }>> {
|
||||
const correlationId = input.correlationId ?? randomUUID();
|
||||
const fail = (error: EnrollmentErrorCode, message: string): EnrollmentFailure => ({
|
||||
ok: false,
|
||||
error,
|
||||
message,
|
||||
correlationId,
|
||||
});
|
||||
|
||||
const harness = input.harness.trim();
|
||||
const name = input.name.trim();
|
||||
if (harness.length === 0) return fail('validation_failed', 'harness must be non-empty');
|
||||
if (name.length === 0 || name.length > 200) {
|
||||
return fail('validation_failed', 'name must be non-empty and at most 200 characters');
|
||||
}
|
||||
if (input.replayMode !== undefined && input.replayMode !== 'actor-bound') {
|
||||
// Seed-only rule (contract 3 §4.3): refused with nothing executed and no fence row.
|
||||
return fail('validation_failed', 'replayMode must be actor-bound');
|
||||
}
|
||||
if (input.credential.mode === 'reference') {
|
||||
if (input.credential.type !== undefined || input.credential.value !== undefined) {
|
||||
return fail('validation_failed', 'a reference credential carries no type or value');
|
||||
}
|
||||
} else if (
|
||||
input.credential.type !== 'api_key' ||
|
||||
typeof input.credential.value !== 'string' ||
|
||||
input.credential.value.length === 0
|
||||
) {
|
||||
return fail('validation_failed', 'an intake credential requires type api_key and a value');
|
||||
}
|
||||
// Syntactic validity ends above; a well-formed name the live registry
|
||||
// does not know is a precondition failure (§3.1 table).
|
||||
if (!this.registry.has(harness)) {
|
||||
return fail('precondition_failed', 'harness is not registered');
|
||||
}
|
||||
|
||||
const normalized: NormalizedEnrollment = {
|
||||
actorId: input.actorId,
|
||||
harness,
|
||||
name,
|
||||
persona: input.persona ?? null,
|
||||
model: input.model,
|
||||
provider: input.provider,
|
||||
credential: input.credential,
|
||||
idempotencyKey: input.idempotencyKey,
|
||||
correlationId,
|
||||
digest: digestOf({
|
||||
harness,
|
||||
name,
|
||||
persona: input.persona ?? null,
|
||||
model: input.model,
|
||||
provider: input.provider,
|
||||
credential: input.credential,
|
||||
}),
|
||||
};
|
||||
|
||||
// Two attempts: a fence-race loser's transaction rolls back and the retry
|
||||
// resolves through the replay path against the winner's committed row —
|
||||
// or executes afresh if the winner aborted (§3.1 rule 5 concurrency). A
|
||||
// unique-violation race never surfaces as an unhandled internal fault.
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
try {
|
||||
return await this.db.transaction(async (tx) => this.enrollTx(tx, normalized));
|
||||
} catch (error) {
|
||||
if (error instanceof ConcurrentEnrollmentError && attempt === 0) continue;
|
||||
if (error instanceof ConcurrentEnrollmentError) {
|
||||
return fail('conflict', CONFLICT_MESSAGE);
|
||||
}
|
||||
// §4.4 fail-closed: whatever broke, the transaction rolled back and
|
||||
// the refusal is the internal-fault class — no fallback write or read.
|
||||
this.logger.error(
|
||||
`agent.enroll failed closed (correlation=${correlationId}): ${
|
||||
error instanceof Error ? error.name : 'unknown error'
|
||||
}`,
|
||||
);
|
||||
return fail('internal_fault', 'internal fault');
|
||||
}
|
||||
}
|
||||
return fail('internal_fault', 'internal fault');
|
||||
}
|
||||
|
||||
private async enrollTx(
|
||||
tx: Tx,
|
||||
input: NormalizedEnrollment,
|
||||
): Promise<EnrollmentResult<{ agent: EnrolledAgentView }>> {
|
||||
const fence = await this.fenceFor(tx, input.idempotencyKey);
|
||||
if (fence) return this.replay(tx, fence, input);
|
||||
|
||||
if (input.credential.mode === 'reference') {
|
||||
// §3.1 rule 3: the reference must resolve for (actor, provider).
|
||||
const existing = await tx
|
||||
.select({ id: providerCredentials.id })
|
||||
.from(providerCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(providerCredentials.userId, input.actorId),
|
||||
eq(providerCredentials.provider, input.provider),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (existing.length === 0) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'precondition_failed',
|
||||
message: 'credential reference does not resolve',
|
||||
correlationId: input.correlationId,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// §3.1 rule 2: sealed-store write inside THIS transaction — a later
|
||||
// failure rolls it back, leaving no orphan credential.
|
||||
await this.writeSealedCredential(
|
||||
tx,
|
||||
input.actorId,
|
||||
input.provider,
|
||||
input.credential.value as string,
|
||||
);
|
||||
}
|
||||
|
||||
const agentRow = await this.insertAgentRow(tx, input);
|
||||
const fenceRow = await this.insertFenceRow(tx, input, agentRow.id);
|
||||
if (!fenceRow) {
|
||||
// A same-(operation, key) winner committed first; abandon our writes.
|
||||
throw new ConcurrentEnrollmentError();
|
||||
}
|
||||
await this.appendEvent(tx, {
|
||||
eventType: 'agent.enrolled',
|
||||
actorId: input.actorId,
|
||||
agentId: agentRow.id,
|
||||
correlationId: input.correlationId,
|
||||
// §3.1 rule 6 payload: harness, provider, name, credentialMode — no credential material.
|
||||
payload: {
|
||||
harness: input.harness,
|
||||
provider: input.provider,
|
||||
name: input.name,
|
||||
credentialMode: input.credential.mode,
|
||||
},
|
||||
});
|
||||
return { ok: true, correlationId: input.correlationId, agent: agentView(agentRow) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Replay path (§3.1 rule 5): a fresh submission of a recorded
|
||||
* (operation, key). The actor is re-authorized exactly as a fresh
|
||||
* submission (v1: authenticated actor — the guard already ran); then mode,
|
||||
* scope, digest, and recorded-actor equality; then target-result read
|
||||
* authority (owner or admin) on the referenced agent. ANY failure refuses
|
||||
* with the single bounded conflict shape — constant, identifying no record.
|
||||
* A passing replay executes nothing and appends only the non-mutation
|
||||
* access event (with its outbox record — one outbox row per event).
|
||||
*/
|
||||
private async replay(
|
||||
tx: Tx,
|
||||
fence: FenceRow,
|
||||
input: NormalizedEnrollment,
|
||||
): Promise<EnrollmentResult<{ agent: EnrolledAgentView }>> {
|
||||
const collision: EnrollmentFailure = {
|
||||
ok: false,
|
||||
error: 'conflict',
|
||||
message: CONFLICT_MESSAGE,
|
||||
correlationId: input.correlationId,
|
||||
};
|
||||
if (fence.replayMode !== 'actor-bound') return collision;
|
||||
if (fence.authorizationScope !== AUTHORIZATION_SCOPE) return collision;
|
||||
if (fence.payloadDigest !== input.digest) return collision;
|
||||
if (fence.actorId !== input.actorId) return collision;
|
||||
|
||||
const rows = await tx.select().from(agents).where(eq(agents.id, fence.outcomeAgentId)).limit(1);
|
||||
const agentRow = rows[0];
|
||||
if (!agentRow) return collision;
|
||||
const authorized =
|
||||
agentRow.ownerId === input.actorId || (await this.isPlatformAdmin(tx, input.actorId));
|
||||
if (!authorized) return collision;
|
||||
|
||||
await this.appendEvent(tx, {
|
||||
eventType: 'agent.enrollment.replayed',
|
||||
actorId: input.actorId,
|
||||
agentId: agentRow.id,
|
||||
correlationId: input.correlationId,
|
||||
payload: { fenceId: fence.id },
|
||||
});
|
||||
return { ok: true, correlationId: input.correlationId, agent: agentView(agentRow) };
|
||||
}
|
||||
|
||||
/**
|
||||
* agent.enrollment.get (§3.2): owner-or-admin read. Unauthorized and
|
||||
* missing fold to the same not_found wire shape (no existence oracle).
|
||||
*/
|
||||
async getEnrollment(
|
||||
actorId: string,
|
||||
agentId: string,
|
||||
correlationId?: string,
|
||||
): Promise<EnrollmentResult<{ agent: EnrolledAgentView }>> {
|
||||
const resolvedCorrelation = correlationId ?? randomUUID();
|
||||
try {
|
||||
const rows = await this.db.select().from(agents).where(eq(agents.id, agentId)).limit(1);
|
||||
const row = rows[0];
|
||||
if (row) {
|
||||
const authorized =
|
||||
row.ownerId === actorId || (await this.isPlatformAdmin(this.db, actorId));
|
||||
if (authorized) {
|
||||
return { ok: true, correlationId: resolvedCorrelation, agent: agentView(row) };
|
||||
}
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: 'not_found',
|
||||
message: NOT_FOUND_MESSAGE,
|
||||
correlationId: resolvedCorrelation,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`agent.enrollment.get failed closed (correlation=${resolvedCorrelation}): ${
|
||||
error instanceof Error ? error.name : 'unknown error'
|
||||
}`,
|
||||
);
|
||||
return {
|
||||
ok: false,
|
||||
error: 'internal_fault',
|
||||
message: 'internal fault',
|
||||
correlationId: resolvedCorrelation,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async fenceFor(tx: Tx, idempotencyKey: string): Promise<FenceRow | null> {
|
||||
const rows = await tx
|
||||
.select()
|
||||
.from(agentIdempotencyFence)
|
||||
.where(
|
||||
and(
|
||||
eq(agentIdempotencyFence.operation, ENROLLMENT_OPERATION),
|
||||
eq(agentIdempotencyFence.idempotencyKey, idempotencyKey),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
private async isPlatformAdmin(tx: Tx, actorId: string): Promise<boolean> {
|
||||
const rows = await tx
|
||||
.select({ role: users.role })
|
||||
.from(users)
|
||||
.where(eq(users.id, actorId))
|
||||
.limit(1);
|
||||
return rows[0]?.role === 'admin';
|
||||
}
|
||||
|
||||
/**
|
||||
* Sealed intake write, mirroring ProviderCredentialsService.store semantics
|
||||
* (seal-at-rest, one row per (userId, provider)) but on the enrollment
|
||||
* transaction (§3.1 rule 2). The plaintext exists only in this frame.
|
||||
*/
|
||||
async writeSealedCredential(
|
||||
tx: Tx,
|
||||
userId: string,
|
||||
provider: string,
|
||||
value: string,
|
||||
): Promise<void> {
|
||||
const encryptedValue = seal(value);
|
||||
await tx
|
||||
.insert(providerCredentials)
|
||||
.values({ userId, provider, credentialType: 'api_key', encryptedValue, metadata: null })
|
||||
.onConflictDoUpdate({
|
||||
target: [providerCredentials.userId, providerCredentials.provider],
|
||||
set: {
|
||||
credentialType: 'api_key',
|
||||
encryptedValue,
|
||||
metadata: null,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async insertAgentRow(tx: Tx, input: NormalizedEnrollment): Promise<AgentRow> {
|
||||
const rows = await tx
|
||||
.insert(agents)
|
||||
.values({
|
||||
name: input.name,
|
||||
provider: input.provider,
|
||||
model: input.model,
|
||||
harness: input.harness,
|
||||
systemPrompt: input.persona,
|
||||
// §3.1 rule 4: owner is the authenticated actor; is_system stays default false.
|
||||
ownerId: input.actorId,
|
||||
enrolledAt: new Date(),
|
||||
})
|
||||
.returning();
|
||||
const row = rows[0];
|
||||
if (!row) throw new Error('agent insert returned no row');
|
||||
return row;
|
||||
}
|
||||
|
||||
async insertFenceRow(
|
||||
tx: Tx,
|
||||
input: NormalizedEnrollment,
|
||||
outcomeAgentId: string,
|
||||
): Promise<FenceRow | null> {
|
||||
const rows = await tx
|
||||
.insert(agentIdempotencyFence)
|
||||
.values({
|
||||
operation: ENROLLMENT_OPERATION,
|
||||
idempotencyKey: input.idempotencyKey,
|
||||
actorId: input.actorId,
|
||||
authorizationScope: AUTHORIZATION_SCOPE,
|
||||
payloadDigest: input.digest,
|
||||
replayMode: 'actor-bound',
|
||||
outcomeAgentId,
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.returning();
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
/** Append one audit event and its outbox record on the caller's transaction (one outbox row per event). */
|
||||
async appendEvent(
|
||||
tx: Tx,
|
||||
input: {
|
||||
eventType: 'agent.enrolled' | 'agent.enrollment.replayed';
|
||||
actorId: string;
|
||||
agentId: string;
|
||||
correlationId: string;
|
||||
payload: Record<string, unknown>;
|
||||
causationId?: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
const inserted = await tx
|
||||
.insert(agentAuditEvents)
|
||||
.values({
|
||||
eventType: input.eventType,
|
||||
actorId: input.actorId,
|
||||
agentId: input.agentId,
|
||||
correlationId: input.correlationId,
|
||||
causationId: input.causationId ?? null,
|
||||
payload: input.payload,
|
||||
})
|
||||
.returning();
|
||||
const event = inserted[0];
|
||||
if (!event) throw new Error('agent audit event insert returned no row');
|
||||
await this.insertOutboxRow(tx, event.id, input.correlationId);
|
||||
}
|
||||
|
||||
async insertOutboxRow(tx: Tx, eventId: string, correlationId: string): Promise<void> {
|
||||
await tx.insert(agentOutbox).values({ eventId, correlationId });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
|
||||
import type {
|
||||
EnrollmentErrorCode,
|
||||
EnrollmentFailure,
|
||||
EnrollmentResult,
|
||||
} from './enrollment.repository.js';
|
||||
|
||||
/**
|
||||
* Maps enrollment result unions onto the closed HTTP status set (design
|
||||
* docs/plans/2026-08-29-agent-enrollment-command-design.md §3.3, contract 5
|
||||
* §4.2). Every refusal body carries the correlation id (contract 5 §4.3
|
||||
* end-to-end traceability) alongside the enum code. `not_found` carries one
|
||||
* fixed message for every cause — missing agent and unauthorized caller are
|
||||
* indistinguishable on the wire (§3.2).
|
||||
*/
|
||||
const HTTP_STATUS: Record<EnrollmentErrorCode, HttpStatus> = {
|
||||
validation_failed: HttpStatus.BAD_REQUEST,
|
||||
authentication_failed: HttpStatus.UNAUTHORIZED,
|
||||
authorization_refused: HttpStatus.FORBIDDEN,
|
||||
not_found: HttpStatus.NOT_FOUND,
|
||||
conflict: HttpStatus.CONFLICT,
|
||||
precondition_failed: HttpStatus.UNPROCESSABLE_ENTITY,
|
||||
internal_fault: HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class EnrollmentService {
|
||||
unwrap<T>(result: EnrollmentResult<T>): { ok: true; correlationId: string } & T {
|
||||
if (result.ok) return result;
|
||||
throw this.toException(result);
|
||||
}
|
||||
|
||||
private toException(failure: EnrollmentFailure): HttpException {
|
||||
const status = HTTP_STATUS[failure.error];
|
||||
return new HttpException(
|
||||
{
|
||||
statusCode: status,
|
||||
error: failure.error,
|
||||
message: failure.message,
|
||||
correlationId: failure.correlationId,
|
||||
},
|
||||
status,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,11 @@ import {
|
||||
TransferEstateDto,
|
||||
TransferPlatformProjectDto,
|
||||
} from './hierarchy/hierarchy.dto.js';
|
||||
import {
|
||||
EnrollAgentDto,
|
||||
EnrollCredentialDto,
|
||||
GetEnrollmentQueryDto,
|
||||
} from './enrollment/enrollment.dto.js';
|
||||
|
||||
/**
|
||||
* Boot-time self-check: the global ValidationPipe must be able to SEE the
|
||||
@@ -105,6 +110,31 @@ export const PIPE_GUARDED_DTOS: Array<{
|
||||
target: ChangeGrantDto,
|
||||
properties: ['role', 'idempotencyKey'],
|
||||
},
|
||||
{
|
||||
name: 'EnrollAgentDto',
|
||||
target: EnrollAgentDto,
|
||||
properties: [
|
||||
'harness',
|
||||
'name',
|
||||
'persona',
|
||||
'model',
|
||||
'provider',
|
||||
'credential',
|
||||
'idempotencyKey',
|
||||
'correlationId',
|
||||
'replayMode',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'EnrollCredentialDto',
|
||||
target: EnrollCredentialDto,
|
||||
properties: ['mode', 'type', 'value'],
|
||||
},
|
||||
{
|
||||
name: 'GetEnrollmentQueryDto',
|
||||
target: GetEnrollmentQueryDto,
|
||||
properties: ['correlationId'],
|
||||
},
|
||||
];
|
||||
|
||||
export class PipeMetatypeCheckError extends Error {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# Explicit, single-seat dogfood mode for stack-containerization B2.
|
||||
# Use with docker-compose.yml. The base stack remains credential-free.
|
||||
services:
|
||||
gateway:
|
||||
environment:
|
||||
# Identity and credential layout match a fleet seat. This fixed name prevents
|
||||
# an operator from mounting one seat while attributing actions to another.
|
||||
MOSAIC_AGENT_NAME: stack-dogfood
|
||||
MOSAIC_GIT_IDENTITY: stack-dogfood
|
||||
MOSAIC_BRAIN_HOME: /opt/mosaic/brain
|
||||
AGENT_FILE_SANDBOX_DIR: /workspace/stack
|
||||
AGENT_USER_TOOLS: fs_read_file,fs_write_file,fs_list_directory,fs_edit_file,git_status,git_log,git_diff,shell_exec
|
||||
volumes:
|
||||
# Mount a dedicated worktree, never the canonical clone or divergent local main.
|
||||
- type: bind
|
||||
source: ${MOSAIC_DOGFOOD_WORKTREE:?set to a dedicated next-based stack worktree}
|
||||
target: /workspace/stack
|
||||
# Only this seat home enters the container. Other fleet credentials stay outside.
|
||||
- type: bind
|
||||
source: ${MOSAIC_DOGFOOD_SEAT_HOME:?set to the external stack-dogfood seat directory}
|
||||
target: /opt/mosaic/brain/fleet/agents/stack-dogfood
|
||||
read_only: true
|
||||
@@ -47,6 +47,44 @@ services:
|
||||
environment:
|
||||
COLLECTOR_OTLP_ENABLED: 'true'
|
||||
|
||||
|
||||
gateway:
|
||||
# Standalone-tier application service (compose `stack` profile).
|
||||
# Default image = local build of docker/gateway.Dockerfile (works with
|
||||
# no registry auth); override GATEWAY_IMAGE to a CI-published sha tag
|
||||
# for registry deploys (git.mosaicstack.dev/mosaicstack/stack/gateway:sha-XXXXXXX).
|
||||
profiles: [stack]
|
||||
image: ${GATEWAY_IMAGE:-mosaic-gateway:dev}
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/gateway.Dockerfile
|
||||
ports:
|
||||
- '${GATEWAY_HOST_PORT:-14242}:14242'
|
||||
environment:
|
||||
GATEWAY_PORT: '14242'
|
||||
DATABASE_URL: postgresql://mosaic:mosaic@postgres:5432/mosaic
|
||||
VALKEY_URL: valkey://valkey:6379
|
||||
# The compose IS the standalone tier by declaration (mode contract:
|
||||
# mode chosen at install); pinning skips cross-container probe races.
|
||||
MOSAIC_STORAGE_TIER: standalone
|
||||
# Standalone-tier secrets: generated at install (see .env.example).
|
||||
# Enterprise tier replaces these with Vault/Openbao plumbing.
|
||||
BETTER_AUTH_SECRET: '${BETTER_AUTH_SECRET:?set in .env — openssl rand -hex 32}'
|
||||
volumes:
|
||||
- gateway_workspaces:/opt/mosaic/.workspaces
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
valkey:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'wget -qO- http://127.0.0.1:14242/health || exit 1']
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 20s
|
||||
|
||||
volumes:
|
||||
gateway_workspaces:
|
||||
pg_data:
|
||||
valkey_data:
|
||||
|
||||
@@ -29,11 +29,23 @@ ENV NODE_ENV=production
|
||||
# $MOSAIC_ROOT/.workspaces (apps/gateway/src/workspace/workspace.service.ts);
|
||||
# mount a volume over /opt/mosaic to persist workspaces across container restarts.
|
||||
# Intentionally unpinned: Alpine's signed repository is the trust anchor; pinning
|
||||
# git was declined so routine base-image security updates remain maintainable.
|
||||
RUN apk add --no-cache git \
|
||||
# packages was declined so routine base-image security updates remain maintainable.
|
||||
# bash/curl/python3 are runtime dependencies of the provider-neutral Mosaic git
|
||||
# wrappers. jq supports wrapper discovery for non-canonical Gitea hosts.
|
||||
RUN apk add --no-cache bash curl git jq python3 \
|
||||
&& mkdir -p /opt/mosaic/.workspaces \
|
||||
&& chown -R node:node /opt/mosaic /app
|
||||
ENV MOSAIC_ROOT=/opt/mosaic
|
||||
# Dogfood agents use the same fail-closed credential helper and PR-create wrapper
|
||||
# as fleet seats. Copy only that operation and its shared dependencies. Unrelated
|
||||
# fleet operations, including merge and infrastructure tools, stay out of the image.
|
||||
COPY --from=builder /app/packages/mosaic/framework/tools/git/pr-create.sh /opt/mosaic/tools/git/pr-create.sh
|
||||
COPY --from=builder /app/packages/mosaic/framework/tools/git/detect-platform.sh /opt/mosaic/tools/git/detect-platform.sh
|
||||
COPY --from=builder /app/packages/mosaic/framework/tools/git/repo-decl.sh /opt/mosaic/tools/git/repo-decl.sh
|
||||
COPY --from=builder /app/packages/mosaic/framework/tools/git/git-credential-mosaic /opt/mosaic/tools/git/git-credential-mosaic
|
||||
COPY --from=builder /app/packages/mosaic/framework/tools/_lib/credentials.sh /opt/mosaic/tools/_lib/credentials.sh
|
||||
COPY --from=builder /app/packages/mosaic/framework/tools/structure/validate-repo-json.sh /opt/mosaic/tools/structure/validate-repo-json.sh
|
||||
RUN git config --system credential.helper /opt/mosaic/tools/git/git-credential-mosaic
|
||||
# Use the pnpm deploy output — resolves all deps into a flat, self-contained node_modules
|
||||
COPY --chown=node:node --from=builder /deploy/node_modules ./node_modules
|
||||
COPY --chown=node:node --from=builder /deploy/package.json ./package.json
|
||||
|
||||
+34
-17
@@ -197,24 +197,41 @@ conflict must amend one of them explicitly, never fork a third document
|
||||
- A second writable task store beside PostgreSQL (native-kanban-sot invariants).
|
||||
- Fully-designed federation in v1 (D3 — roadmap placeholder only).
|
||||
|
||||
### 12. Decision registry
|
||||
### D15 — Tiered containerized deployment (2026-08-30, containerization lane)
|
||||
|
||||
| ID | Decision (short form) |
|
||||
| --- | ------------------------------------------------------------------------------------------------------------------ |
|
||||
| D1 | Open-source, AI-first, self-hosted platform for agentic management + life OS |
|
||||
| D2 | Hierarchy company→estate→project→workspace→kanban; bubble-up; granular RBAC |
|
||||
| D3 | Standalone vs Enterprise; one-way conversion; per-user brains + Vault required in Enterprise; federation deferred |
|
||||
| D4 | Re-runnable, extensible, per-mode onboarding wizards |
|
||||
| D5 | North star = this rewrite of docs/PRD.md; stack docs/ = product SSOT |
|
||||
| D6 | Only product-relevant material migrates from brains; operational records stay and link |
|
||||
| D7 | Spec-inventory sweep launched immediately (executed; INPUTS baseline frozen by operator ruling T2, 2026-08-25) |
|
||||
| D8 | webUI sits over official framework tooling; CLI primary |
|
||||
| D9 | Not a hosted business; company = organizational separation for one operator |
|
||||
| D10 | better-auth is the account system of record; external IdPs via OIDC |
|
||||
| D11 | Small v1 slice; ALL phases on the documented roadmap from day one |
|
||||
| D12 | HARD RULE: webUI never bypasses tooling; missing tool ⇒ build the tool first |
|
||||
| D13 | workspace_id stays the hard isolation unit; hierarchy is parent structure above; kanban SOT amended, not rewritten |
|
||||
| D14 | Sensitive profile data in the user's own brain only; postgres holds structure/consent/pointers |
|
||||
The stack ships a tiered deployment target, additive to the architecture
|
||||
gate (D8): (1) Standalone tier — docker compose is the canonical
|
||||
single-host deployment: postgres, valkey, openbao, gateway, appservice
|
||||
and the served webUI in one composition, with migrations, health checks,
|
||||
and a documented install/upgrade path; the registry (CI-published
|
||||
images) is the only deployment source. (2) Enterprise tier — Kubernetes
|
||||
manifests for the same service set, phase-gated on the standalone tier
|
||||
holding its acceptance bar. The v1 acceptance bar for the standalone
|
||||
tier: compose-up healthy; webUI hosts agent chat; an in-stack agent can
|
||||
open a PR to this repo; CI validates it; the running deployment adopts
|
||||
the merged change (pull + restart). Federation (D3 clause) remains
|
||||
deferred and unforeclosed. Implementation plan:
|
||||
docs/plans/2026-08-30_containerization.md.
|
||||
|
||||
## 12. Decision registry
|
||||
|
||||
| ID | Decision (short form) |
|
||||
| --- | ------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------- |
|
||||
| D1 | Open-source, AI-first, self-hosted platform for agentic management + life OS |
|
||||
| D2 | Hierarchy company→estate→project→workspace→kanban; bubble-up; granular RBAC |
|
||||
| D3 | Standalone vs Enterprise; one-way conversion; per-user brains + Vault required in Enterprise; federation deferred |
|
||||
| D4 | Re-runnable, extensible, per-mode onboarding wizards |
|
||||
| D5 | North star = this rewrite of docs/PRD.md; stack docs/ = product SSOT |
|
||||
| D6 | Only product-relevant material migrates from brains; operational records stay and link |
|
||||
| D7 | Spec-inventory sweep launched immediately (executed; INPUTS baseline frozen by operator ruling T2, 2026-08-25) |
|
||||
| D8 | webUI sits over official framework tooling; CLI primary |
|
||||
| D9 | Not a hosted business; company = organizational separation for one operator |
|
||||
| D10 | better-auth is the account system of record; external IdPs via OIDC |
|
||||
| D11 | Small v1 slice; ALL phases on the documented roadmap from day one |
|
||||
| D12 | HARD RULE: webUI never bypasses tooling; missing tool ⇒ build the tool first |
|
||||
| D13 | workspace_id stays the hard isolation unit; hierarchy is parent structure above; kanban SOT amended, not rewritten |
|
||||
| D14 | Sensitive profile data in the user's own brain only; postgres holds structure/consent/pointers |
|
||||
| D15 | Tiered containerized deployment: compose standalone tier (five-point v1 bar) + phase-gated k8s enterprise tier; registry-only image source | 2026-08-30 containerization lane; plan docs/plans/2026-08-30_containerization.md |
|
||||
|
||||
The full decision texts are recorded in the operator decision log (USC estate
|
||||
brain, webui-audit lane, `GRILL.md`).
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
# Plan — Stack Containerization (tiered deployment)
|
||||
|
||||
Status: DRAFT for review. Charter: fleet/lanes/stack-containerization
|
||||
(brain) NORTH-STAR.md; PRD amendment in the same PR adds D15.
|
||||
Supersedes nothing; sequences the absorbed M4 remainder per its lane.
|
||||
|
||||
## Measured baseline (origin/next @ 143ba0f5, 2026-08-30)
|
||||
|
||||
- `docker-compose.yml`: dev infrastructure only — postgres (pgvector),
|
||||
valkey, otel-collector, jaeger. No application services.
|
||||
- `docker-compose.federated.yml`: standalone overlay for the FEDERATED
|
||||
storage tier (own postgres/valkey; port-conflicts the base stack by
|
||||
design). Not an app deployment.
|
||||
- `docker/gateway.Dockerfile`, `docker/appservice.Dockerfile`:
|
||||
multi-stage production builds (node:22-alpine) EXIST; the gateway image
|
||||
includes the web SPA bundle (#1444).
|
||||
- CI (`publish.yml`) builds and publishes these images (next-channel
|
||||
prereleases + main stable), and runs `verify:release` fail-closed.
|
||||
- Gap: no stack-level composition wires gateway+appservice+data plane
|
||||
into one deployable unit; no blessed install/upgrade path; no
|
||||
in-container agent-runtime story for the dogfood loop.
|
||||
|
||||
## Target (PRD D15 amendment)
|
||||
|
||||
Tiered deployment, additive to the existing architecture:
|
||||
|
||||
1. **Standalone tier (v1 bar)**: `docker compose up` on one host brings
|
||||
postgres, valkey, openbao, gateway, appservice (and the webUI the
|
||||
gateway serves) to healthy; migrations apply; the webUI hosts agent
|
||||
chat; an in-stack agent can read this repo and open a PR; CI
|
||||
validates; the deployment adopts merged images (pull + restart).
|
||||
2. **Enterprise tier (post-v1)**: Kubernetes manifests (or Helm) for the
|
||||
same service set, phase-gated on the standalone bar holding.
|
||||
|
||||
## Phases
|
||||
|
||||
### Phase A — blessed standalone compose
|
||||
|
||||
- A1 Compose service definitions for gateway + appservice joining the
|
||||
existing infra compose (profiles: `dev` keeps today's behavior;
|
||||
`stack` adds the app tier), with health checks and dependency order.
|
||||
- A2 Migrations on boot (or an explicit migrate step) with idempotency
|
||||
and version pinning; init-db.sql folded into pg-init.
|
||||
- A3 Openbao in the compose set (secret plumbing for the app tier).
|
||||
- A4 `.env.example` + `mosaic.config.json` defaults documented for the
|
||||
standalone mode; mode recorded per the mode-conversion contract.
|
||||
- A5 Smoke: `docker compose --profile stack up` green on a scratch host;
|
||||
webUI served; agent chat reachable; failures catalogued and fixed.
|
||||
- Acceptance: the five-point NORTH-STAR bar measured live.
|
||||
|
||||
### Phase B — component completion
|
||||
|
||||
- Interface assumption (velma verdict A1, P5-RM-005/006): in-stack
|
||||
dogfood agents inherit SEAT-GRADE identity — credential-slot
|
||||
isolation, wrapper-first enforcement, no privileged coordination
|
||||
identity, evidence by references that resolve outside the container
|
||||
lifetime.
|
||||
- Decompose JIT from A5's catalogue. Known candidates: agent runtime
|
||||
bits (brain/tool access paths in-container), repo credentials for the
|
||||
dogfood agent, watch/comms surfaces inside the deployment.
|
||||
|
||||
### Phase C — CI/CD parity
|
||||
|
||||
- Publish pipeline is the only image source (already true); add the
|
||||
deployment-side pull/upgrade path (compose pull + migrate + restart =
|
||||
next iteration); document the promotion flow next -> registry ->
|
||||
deployment.
|
||||
|
||||
### Phase D — coordinator integration (GATED)
|
||||
|
||||
- Gate (velma verdict C2): blocked until the checkpoint-and-lease child
|
||||
of the guides-proposed control-plane refactor — core + WU-P1-CHECKPOINT
|
||||
(schema, freshness, incarnation, clean-replacement resume; D57-D60
|
||||
lineage) — carries an independent target-bound PASS. Wiring restarts
|
||||
against the core alone re-creates the stale-incarnation failure class
|
||||
D57-D60 closed. Transitive: inherits the T108 gates (P0 exit + Jason
|
||||
P1 authorization).
|
||||
- Scope (velma verdict C1): lifecycle actions (start/stop/restart/
|
||||
health/recovery) executed by the SHIPPED coord client over the one
|
||||
typed coordination contract (request id, actor identity, epoch,
|
||||
revision, lease, correlation; typed stale rejection; worker role
|
||||
boundary). No second coordination interface gets designed here —
|
||||
containerization consumes the coordination contract, never defines it.
|
||||
|
||||
### Phase E — enterprise tier
|
||||
|
||||
- k8s manifests/Helm for the same set; phase-gated on Phase A holding.
|
||||
|
||||
### Absorbed M4 remainder
|
||||
|
||||
- M4-3 pivot: KBN-101 foundation first (per ruling R6), then expand DDL.
|
||||
- M4-5: lands inside Phase B/C where natural.
|
||||
- M4-6 (composes M4-1+M4-4): last, as designed.
|
||||
|
||||
## Non-goals (v1)
|
||||
|
||||
- No Kubernetes in v1; no multi-host federation; no replacement of the
|
||||
fleet's brain-based seats (the stack is an additional operator
|
||||
surface); no on-host image builds for deployment (registry only).
|
||||
Executable
+150
@@ -0,0 +1,150 @@
|
||||
#!/usr/bin/env bash
|
||||
# mosaic — fleet launcher (shipped-first, split-home safe).
|
||||
#
|
||||
# T110 / P5-RM-009 stack side. Carries the T106 brain launcher contract
|
||||
# (shipped-first, worktree dev opt-in, OFF pass-through, typed failure) with
|
||||
# one split-home correction: the SHIPPED npm mosaic is resolved from the real
|
||||
# user's home (passwd database), never from $HOME. Under split-home seat
|
||||
# layouts HOME is a seat home: it carries no npm prefix, and a
|
||||
# $HOME/.npm-global there would be a plantable descriptor, so the $HOME
|
||||
# candidate is consulted only when the passwd lookup itself fails, and then
|
||||
# only with a full symlink-component refusal (secure descriptor traversal).
|
||||
#
|
||||
# Contract:
|
||||
# 1. SHIPPED npm mosaic is the default. Candidate order:
|
||||
# a. <real-home>/.npm-global/bin/mosaic — real home from the passwd
|
||||
# database. The final component may be npm's own bin symlink into
|
||||
# lib/node_modules; that indirection is npm's layout, not a plant.
|
||||
# b. $HOME/.npm-global/bin/mosaic — ONLY when the passwd lookup
|
||||
# fails, and then only when the candidate is a trusted-shape
|
||||
# absolute path: relative HOME and parent-escape (..) components
|
||||
# are refused outright, and every remaining component must be a
|
||||
# non-symlink (secure descriptor traversal). Refused candidates
|
||||
# are never executed.
|
||||
# 2. Worktree build is DEV OPT-IN: used only when MOSAIC_CLI_WORKTREE is
|
||||
# explicitly set. Health-checked via --version; ANY doubt (absent,
|
||||
# unreadable, or failing) falls back to the shipped npm mosaic with a
|
||||
# warning on stderr. With no environment set, worktree candidates are
|
||||
# never consulted — stale worktree builds cannot regain precedence.
|
||||
# 3. MOSAIC_FLEET_CLI_OFF keeps its pass-through semantics: set (any
|
||||
# value) forces pure pass-through. The dev path is not consulted even
|
||||
# when MOSAIC_CLI_WORKTREE is also set.
|
||||
# 4. Typed failure: with no runnable candidate the launcher prints one
|
||||
# stderr line naming what was checked and exits 127.
|
||||
# 5. NEVER writes to the mosaic home or the npm prefix. Deployment to the
|
||||
# fleet goes through the real channel (PR to next -> mosaic update).
|
||||
#
|
||||
# Env:
|
||||
# MOSAIC_CLI_WORKTREE dev opt-in: path to a stack worktree whose
|
||||
# packages/mosaic/dist/cli.js is used (health-checked,
|
||||
# shipped fallback on doubt)
|
||||
# MOSAIC_FLEET_CLI_OFF set (any value) to force pure pass-through
|
||||
#
|
||||
# Component walk note: the descriptor guard splits on "/" without quoting so
|
||||
# multi-byte HOME paths with spaces are not supported for the FALLBACK
|
||||
# candidate; the passwd candidate needs no walk (trusted derivation).
|
||||
|
||||
set -u
|
||||
|
||||
fail() {
|
||||
echo "mosaic: $*" >&2
|
||||
exit 127
|
||||
}
|
||||
|
||||
# Real user home from the passwd database (HOME-independent).
|
||||
real_home() {
|
||||
getent passwd "$(id -u)" 2>/dev/null | cut -d: -f6
|
||||
}
|
||||
|
||||
# True when any component of an ABSOLUTE candidate path is a symlink. Only
|
||||
# ever called after fallback_candidate_usable's absolute-shape check.
|
||||
path_has_symlink_component() {
|
||||
local path="$1" dir base acc="" part
|
||||
dir="$(dirname -- "$path")"
|
||||
base="$(basename -- "$path")"
|
||||
local IFS='/'
|
||||
for part in $dir; do
|
||||
acc="$acc/$part"
|
||||
[ -L "$acc" ] && return 0
|
||||
done
|
||||
[ -L "$dir/$base" ] && return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
# Reject the untrusted $HOME fallback candidate unless it is a trusted-shape
|
||||
# absolute path: absolute, no parent-escape (..) components, and no symlink
|
||||
# components anywhere on the path. Every rejection is named on stderr so the
|
||||
# typed failure explains itself. This is the launcher's descriptor guard; the
|
||||
# suite's mutation control (guard bypassed) must plant-exec, proving the guard
|
||||
# is what stands between a hostile HOME and code execution.
|
||||
fallback_candidate_usable() {
|
||||
local candidate="$1"
|
||||
case "$candidate" in
|
||||
/*) ;;
|
||||
*)
|
||||
echo "mosaic: refusing \$HOME candidate $candidate: relative path is untrusted without a passwd home" >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
if printf '%s' "$candidate" | grep -qE '(^|/)\.\.(/|$)'; then
|
||||
echo "mosaic: refusing \$HOME candidate $candidate: parent-escape component" >&2
|
||||
return 1
|
||||
fi
|
||||
if path_has_symlink_component "$candidate"; then
|
||||
echo "mosaic: refusing \$HOME candidate $candidate: symlink component (untrusted without a passwd home)" >&2
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# Print shipped candidates in contract order. Refusals are reported on stderr
|
||||
# so the typed failure names the cause.
|
||||
shipped_candidates() {
|
||||
local rh home_candidate
|
||||
rh="$(real_home)"
|
||||
if [ -n "$rh" ]; then
|
||||
printf '%s\n' "$rh/.npm-global/bin/mosaic"
|
||||
return 0
|
||||
fi
|
||||
# passwd lookup failed: the only fallback is $HOME, descriptor-guarded.
|
||||
if [ -n "${HOME:-}" ]; then
|
||||
home_candidate="$HOME/.npm-global/bin/mosaic"
|
||||
if fallback_candidate_usable "$home_candidate"; then
|
||||
printf '%s\n' "$home_candidate"
|
||||
fi
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
resolve_shipped() {
|
||||
local candidate
|
||||
while IFS= read -r candidate; do
|
||||
[ -n "$candidate" ] || continue
|
||||
if [ -x "$candidate" ]; then
|
||||
printf '%s\n' "$candidate"
|
||||
return 0
|
||||
fi
|
||||
done < <(shipped_candidates)
|
||||
return 1
|
||||
}
|
||||
|
||||
# Dev opt-in only: an explicit MOSAIC_CLI_WORKTREE reaches the worktree build,
|
||||
# and pure pass-through (MOSAIC_FLEET_CLI_OFF) outranks it.
|
||||
if [ -n "${MOSAIC_CLI_WORKTREE:-}" ] && [ -z "${MOSAIC_FLEET_CLI_OFF:-}" ]; then
|
||||
CLI="$MOSAIC_CLI_WORKTREE/packages/mosaic/dist/cli.js"
|
||||
if [ -r "$CLI" ]; then
|
||||
if v="$(node "$CLI" --version 2>/dev/null)" && [ -n "$v" ]; then
|
||||
exec node "$CLI" "$@"
|
||||
fi
|
||||
echo "mosaic: worktree build at $CLI failed its health check; using shipped npm mosaic" >&2
|
||||
else
|
||||
echo "mosaic: worktree build at $CLI absent or unreadable; using shipped npm mosaic" >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
SHIPPED="$(resolve_shipped)" || true
|
||||
if [ -n "${SHIPPED:-}" ]; then
|
||||
exec "$SHIPPED" "$@"
|
||||
fi
|
||||
|
||||
fail "no runnable CLI (shipped npm mosaic absent from the passwd-home npm prefix and \$HOME; worktree build requires MOSAIC_CLI_WORKTREE)"
|
||||
+193
@@ -0,0 +1,193 @@
|
||||
#!/usr/bin/env bash
|
||||
# Hermetic suite for the fleet/bin/mosaic launcher (T110 / P5-RM-009).
|
||||
#
|
||||
# Arms cover the plan acceptance: split-home shipped-first positive, typed
|
||||
# failure on missing candidates, stale-worktree non-precedence, OFF
|
||||
# pass-through, and secure-descriptor refusal on the untrusted $HOME
|
||||
# fallback. No network, no real npm install, no node package build: the
|
||||
# "shipped mosaic" is a stub script and getent is PATH-stubbed (set
|
||||
# GETENT_STUB=fail to make the passwd lookup fail, exercising the guarded
|
||||
# $HOME fallback).
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR=$(cd -- "$(dirname -- "$0")" && pwd)
|
||||
LAUNCHER="$SCRIPT_DIR/mosaic"
|
||||
|
||||
fail() {
|
||||
echo "FAIL: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
[ -f "$LAUNCHER" ] || fail "missing launcher"
|
||||
[ -x "$LAUNCHER" ] || fail "launcher is not executable"
|
||||
bash -n "$LAUNCHER" || fail "launcher fails bash -n"
|
||||
|
||||
WORK=$(mktemp -d)
|
||||
cleanup() { rm -rf "$WORK"; }
|
||||
trap cleanup EXIT
|
||||
|
||||
REAL_HOME="$WORK/real-home"
|
||||
SEAT_HOME="$WORK/seat-home"
|
||||
STUB_BIN="$WORK/stub-bin"
|
||||
mkdir -p "$REAL_HOME/.npm-global/bin" "$SEAT_HOME" "$STUB_BIN"
|
||||
|
||||
cat >"$REAL_HOME/.npm-global/bin/mosaic" <<'SH'
|
||||
#!/bin/sh
|
||||
echo "0.0.0-shipped-stub"
|
||||
SH
|
||||
chmod +x "$REAL_HOME/.npm-global/bin/mosaic"
|
||||
|
||||
# PATH-stubbed getent: reports the real home for the current uid, unless
|
||||
# GETENT_STUB=fail is in the launcher environment (exercises the guarded
|
||||
# $HOME fallback path).
|
||||
cat >"$STUB_BIN/getent" <<SH
|
||||
#!/bin/sh
|
||||
if [ "\${GETENT_STUB:-}" = "fail" ]; then exit 2; fi
|
||||
if [ "\$1" = "passwd" ]; then
|
||||
echo "stub:x:$(id -u):$(id -g):stub:$REAL_HOME:/bin/sh"
|
||||
exit 0
|
||||
fi
|
||||
exit 2
|
||||
SH
|
||||
chmod +x "$STUB_BIN/getent"
|
||||
|
||||
run_launcher() { # run_launcher <home> [VAR=value ...] -- [args...]
|
||||
local home="$1"; shift
|
||||
[ "${1:-}" = "--" ] && shift
|
||||
env -i PATH="$STUB_BIN:/usr/bin:/bin" HOME="$home" TERM="${TERM:-dumb}" "$LAUNCHER" "$@"
|
||||
}
|
||||
|
||||
# A1 — acceptance 1: split-home positive. HOME is an empty seat home; the
|
||||
# shipped mosaic resolves through the passwd real home.
|
||||
out="$(printf '' | run_launcher "$SEAT_HOME" -- --version)"
|
||||
[ "$out" = "0.0.0-shipped-stub" ] || fail "A1 split-home positive: got '$out', want shipped stub version"
|
||||
|
||||
# A3 — acceptance 3: a stale worktree build is NEVER consulted without the
|
||||
# explicit opt-in, even when a worktree exists on disk.
|
||||
WT="$WORK/stale-wt"
|
||||
mkdir -p "$WT/packages/mosaic/dist"
|
||||
printf 'console.log("0.0.0-stale-worktree")\n' >"$WT/packages/mosaic/dist/cli.js"
|
||||
out="$(printf '' | run_launcher "$SEAT_HOME" -- --version)"
|
||||
[ "$out" = "0.0.0-shipped-stub" ] || fail "A3 stale worktree regained precedence without opt-in: got '$out'"
|
||||
|
||||
# A2 (opt-in healthy) — explicit MOSAIC_CLI_WORKTREE reaches the worktree.
|
||||
out="$(printf '' | env MOSAIC_CLI_WORKTREE="$WT" HOME="$SEAT_HOME" PATH="$STUB_BIN:/usr/bin:/bin" "$LAUNCHER" --version)"
|
||||
[ "$out" = "0.0.0-stale-worktree" ] || fail "A2 opt-in worktree not used: got '$out'"
|
||||
|
||||
# A2b (opt-in unhealthy) — absent dist falls back to shipped with a warning.
|
||||
out2="$(printf '' | env MOSAIC_CLI_WORKTREE="$WORK/empty-wt" HOME="$SEAT_HOME" PATH="$STUB_BIN:/usr/bin:/bin" "$LAUNCHER" --version 2>/dev/null)"
|
||||
[ "$out2" = "0.0.0-shipped-stub" ] || fail "A2b unhealthy worktree fallback output: '$out2'"
|
||||
err2="$(printf '' | env MOSAIC_CLI_WORKTREE="$WORK/empty-wt" HOME="$SEAT_HOME" PATH="$STUB_BIN:/usr/bin:/bin" "$LAUNCHER" --version 2>&1 >/dev/null)"
|
||||
case "$err2" in *"absent or unreadable"*|*"health check"*) ;; *) fail "A2b unhealthy worktree fallback warning missing: '$err2'" ;; esac
|
||||
|
||||
# A4 — OFF pass-through: worktree opt-in is ignored when OFF is set.
|
||||
out="$(printf '' | env MOSAIC_FLEET_CLI_OFF=1 MOSAIC_CLI_WORKTREE="$WT" HOME="$SEAT_HOME" PATH="$STUB_BIN:/usr/bin:/bin" "$LAUNCHER" --version)"
|
||||
[ "$out" = "0.0.0-shipped-stub" ] || fail "A4 OFF did not force pass-through: got '$out'"
|
||||
|
||||
# A5 — acceptance 4: typed failure when no candidate exists (passwd lookup
|
||||
# fails, seat home carries no npm prefix). Expect 127 + documented message.
|
||||
set +e
|
||||
err="$(printf '' | env GETENT_STUB=fail HOME="$SEAT_HOME" PATH="$STUB_BIN:/usr/bin:/bin" "$LAUNCHER" --version 2>&1 >/dev/null)"
|
||||
rc=$?
|
||||
set -e
|
||||
[ "$rc" = "127" ] || fail "A5 typed failure rc: got $rc, want 127"
|
||||
case "$err" in *"no runnable CLI"*) ;; *) fail "A5 typed failure message missing: '$err'" ;; esac
|
||||
|
||||
# A6 — secure descriptor traversal, ABSOLUTE symlink plant (corrected per
|
||||
# rev-code-02 B3: the symlink points at $PLANT/.npm-global so the candidate
|
||||
# resolves EXACTLY to the planted executable). passwd lookup fails and a
|
||||
# symlink-planted $HOME/.npm-global is refused without execution.
|
||||
PLANT="$WORK/planted-target"
|
||||
mkdir -p "$PLANT/.npm-global/bin"
|
||||
cat >"$PLANT/.npm-global/bin/mosaic" <<SH
|
||||
#!/bin/sh
|
||||
touch "$WORK/planted-sentinel"
|
||||
echo "0.0.0-planted"
|
||||
SH
|
||||
chmod +x "$PLANT/.npm-global/bin/mosaic"
|
||||
ln -s "$PLANT/.npm-global" "$SEAT_HOME/.npm-global"
|
||||
set +e
|
||||
err="$(printf '' | env GETENT_STUB=fail HOME="$SEAT_HOME" PATH="$STUB_BIN:/usr/bin:/bin" "$LAUNCHER" --version 2>&1)"
|
||||
rc=$?
|
||||
set -e
|
||||
[ "$rc" = "127" ] || fail "A6 planted descriptor was followed (rc $rc, out '$err')"
|
||||
case "$err" in *"symlink component"*) ;; *) fail "A6 refusal diagnostic missing: '$err'" ;; esac
|
||||
[ ! -e "$WORK/planted-sentinel" ] || fail "A6 planted mosaic EXECUTED"
|
||||
|
||||
# A6b — mutation control (rev-code-02 B3): a copy of the launcher with the
|
||||
# descriptor guard bypassed MUST execute the plant under the identical hostile
|
||||
# arm. If the mutant stays clean, the plant path is wrong and A6 proves
|
||||
# nothing.
|
||||
MUTANT="$WORK/mutant-mosaic"
|
||||
sed 's/if fallback_candidate_usable "\$home_candidate"; then/if true; then/' "$LAUNCHER" >"$MUTANT"
|
||||
chmod +x "$MUTANT"
|
||||
[ "$(grep -c 'if true; then' "$MUTANT")" -eq 1 ] || fail "A6b mutant not created (guard call not replaced)"
|
||||
set +e
|
||||
mout="$(printf '' | env GETENT_STUB=fail HOME="$SEAT_HOME" PATH="$STUB_BIN:/usr/bin:/bin" "$MUTANT" --version 2>&1)"
|
||||
mrc=$?
|
||||
set -e
|
||||
[ "$mrc" = "0" ] || fail "A6b mutant did not execute the plant (rc $mrc, out '$mout') - A6 proves nothing"
|
||||
[ -e "$WORK/planted-sentinel" ] || fail "A6b mutant ran but sentinel absent - plant path wrong, A6 proves nothing"
|
||||
|
||||
# A7 — relative-HOME hostile arm (rev-code-02 B2): a relative HOME whose name
|
||||
# is a symlink in the launcher CWD must be refused outright, never resolved
|
||||
# against the working directory.
|
||||
CWD_SANDBOX="$WORK/cwd-sandbox"
|
||||
REL_PLANT="$WORK/relative-plant"
|
||||
mkdir -p "$CWD_SANDBOX" "$REL_PLANT/.npm-global/bin"
|
||||
cat >"$REL_PLANT/.npm-global/bin/mosaic" <<SH
|
||||
#!/bin/sh
|
||||
touch "$WORK/relative-sentinel"
|
||||
echo "0.0.0-relative-planted"
|
||||
SH
|
||||
chmod +x "$REL_PLANT/.npm-global/bin/mosaic"
|
||||
ln -s "$REL_PLANT" "$CWD_SANDBOX/relative-home"
|
||||
set +e
|
||||
rout="$(cd "$CWD_SANDBOX" && printf '' | env GETENT_STUB=fail HOME="relative-home" PATH="$STUB_BIN:/usr/bin:/bin" "$LAUNCHER" --version 2>&1)"
|
||||
rrc=$?
|
||||
set -e
|
||||
[ "$rrc" = "127" ] || fail "A7 relative HOME was followed (rc $rrc, out '$rout')"
|
||||
case "$rout" in *"relative path"*) ;; *) fail "A7 relative-refusal diagnostic missing: '$rout'" ;; esac
|
||||
[ ! -e "$WORK/relative-sentinel" ] || fail "A7 relative plant EXECUTED"
|
||||
|
||||
# A7b — mutation control for the absolute-shape check: the same mutant (guard
|
||||
# bypassed) MUST execute the relative plant under the identical arm.
|
||||
set +e
|
||||
rmout="$(cd "$CWD_SANDBOX" && printf '' | env GETENT_STUB=fail HOME="relative-home" PATH="$STUB_BIN:/usr/bin:/bin" "$MUTANT" --version 2>&1)"
|
||||
rmrc=$?
|
||||
set -e
|
||||
[ "$rmrc" = "0" ] || fail "A7b mutant did not execute the relative plant (rc $rmrc, out '$rmout') - A7 proves nothing"
|
||||
[ -e "$WORK/relative-sentinel" ] || fail "A7b mutant ran but relative sentinel absent - arm wrong, A7 proves nothing"
|
||||
|
||||
# A8 — parent-escape hostile arm (rev-code-02 delta, B2 remains): an absolute
|
||||
# HOME containing a literal '..' component must be refused by the
|
||||
# parent-escape check — the traversal would otherwise land on a planted tree
|
||||
# OUTSIDE the seat home with no symlink involved.
|
||||
ESC_BASE="$WORK/escape-base"
|
||||
ESC_TARGET="$WORK/escape-target"
|
||||
mkdir -p "$ESC_BASE" "$ESC_TARGET/.npm-global/bin"
|
||||
cat >"$ESC_TARGET/.npm-global/bin/mosaic" <<SH
|
||||
#!/bin/sh
|
||||
touch "$WORK/escape-sentinel"
|
||||
echo "0.0.0-escape-planted"
|
||||
SH
|
||||
chmod +x "$ESC_TARGET/.npm-global/bin/mosaic"
|
||||
set +e
|
||||
eout="$(printf '' | env GETENT_STUB=fail HOME="$ESC_BASE/../escape-target" PATH="$STUB_BIN:/usr/bin:/bin" "$LAUNCHER" --version 2>&1)"
|
||||
erc=$?
|
||||
set -e
|
||||
[ "$erc" = "127" ] || fail "A8 parent-escape HOME was followed (rc $erc, out '$eout')"
|
||||
case "$eout" in *"parent-escape component"*) ;; *) fail "A8 parent-escape diagnostic missing: '$eout'" ;; esac
|
||||
[ ! -e "$WORK/escape-sentinel" ] || fail "A8 escape plant EXECUTED"
|
||||
|
||||
# A8b — mutation control: the guard-bypassed copy MUST execute the parent-
|
||||
# escape plant under the identical arm (sentinel present, rc 0), proving the
|
||||
# parent-escape check is what stands.
|
||||
set +e
|
||||
emout="$(printf '' | env GETENT_STUB=fail HOME="$ESC_BASE/../escape-target" PATH="$STUB_BIN:/usr/bin:/bin" "$MUTANT" --version 2>&1)"
|
||||
emrc=$?
|
||||
set -e
|
||||
[ "$emrc" = "0" ] || fail "A8b mutant did not execute the escape plant (rc $emrc, out '$emout') - A8 proves nothing"
|
||||
[ -e "$WORK/escape-sentinel" ] || fail "A8b mutant ran but escape sentinel absent - arm wrong, A8 proves nothing"
|
||||
|
||||
echo "mosaic launcher suite: all arms passed"
|
||||
@@ -46,7 +46,12 @@ systemd/**
|
||||
templates/**
|
||||
tools/**
|
||||
# Fleet: only the framework-seeded fleet subtrees are framework-owned.
|
||||
# fleet/bin is exact-entry on purpose (T110 B1): the estate's fleet/bin carries
|
||||
# operator-owned executables this package does not ship; a subtree glob here
|
||||
# would make keep-mode update prune them.
|
||||
fleet/README.md
|
||||
fleet/bin/mosaic
|
||||
fleet/bin/test-mosaic-launcher.sh
|
||||
fleet/examples/**
|
||||
fleet/profiles/**
|
||||
fleet/roles/**
|
||||
|
||||
@@ -1,220 +1,64 @@
|
||||
#!/bin/bash
|
||||
# git-credential-mosaic — git credential helper. Resolves a Gitea token from the
|
||||
# Mosaic credential store at runtime so remote URLs never embed secrets.
|
||||
#!/usr/bin/python3
|
||||
# git-credential-mosaic — production entrypoint (P0-SEC R4, rev-code-02 B1).
|
||||
#
|
||||
# Install (one-time, per clone or globally):
|
||||
# git config credential.helper "$HOME/.config/mosaic/tools/git/git-credential-mosaic"
|
||||
# WHY THIS IS NOT BASH: three review rounds falsified every in-bash startup
|
||||
# guard. A non-interactive bash sources $BASH_ENV and imports exported
|
||||
# functions BEFORE the first script line, so read(), unset(), exit(),
|
||||
# declare(), printf() — every callable — can be shadows that fake the
|
||||
# ancestry, defeat the scrub, or forge diagnostics (rev-code-02 probes 1 and
|
||||
# 2, artifacts fc49e9d9 lineage). No in-language dispatch survives that.
|
||||
#
|
||||
# Per-agent identity (Gate-16 author != reviewer separation):
|
||||
# git config mosaic.gitIdentity <agent-id> # per-worktree, persists on disk
|
||||
# # or: export MOSAIC_GIT_IDENTITY=<agent-id>
|
||||
# This entrypoint is unshapable at the bash level: python does not read
|
||||
# BASH_ENV and imports no bash functions, and the interpreter is pinned by
|
||||
# absolute shebang (no PATH resolution). It builds the child environment BY
|
||||
# ALLOWLIST and execve's the bash implementation directly — the child bash
|
||||
# starts with no BASH_ENV, no BASH_FUNC_*, no SHELLOPTS/BASHOPTS, and exactly
|
||||
# the variables the credential protocol needs. stdin/stdout/stderr and argv
|
||||
# pass through untouched.
|
||||
#
|
||||
# ── WHY THIS FAILS CLOSED ──────────────────────────────────────────────────────
|
||||
# This helper used to end by emitting the shared account's token for any request
|
||||
# it could not resolve to an identity. A seat with no identity, or with an
|
||||
# identity whose token was never provisioned, therefore received the most
|
||||
# privileged credential configured on the host — silently, and indistinguishably
|
||||
# from correct operation. Every record it then created (commit, push, PR, review)
|
||||
# was attributed to that shared account, so author != reviewer separation was
|
||||
# unenforceable and the true actor was unrecoverable after the fact.
|
||||
#
|
||||
# Under-provisioning must fail loudly, not impersonate. A refused git operation
|
||||
# is recoverable in one command; a merged pull request attributed to the wrong
|
||||
# principal is not.
|
||||
#
|
||||
# ── CONTRACT ───────────────────────────────────────────────────────────────────
|
||||
# identity : MOSAIC_GIT_IDENTITY > git config mosaic.gitIdentity > the
|
||||
# username git supplies on stdin
|
||||
# store : chosen by what the identity IS, with no precedence and no
|
||||
# cross-store fallback (see "Credential store selection" below)
|
||||
# hit : emit username + password, exit 0
|
||||
# miss : emit NOTHING, spool a durable escalation record, explain on
|
||||
# stderr, exit 1 — git surfaces the failure and nothing is attributed
|
||||
# unknown host : exit 0 with no output, no record (passthrough for non-Mosaic
|
||||
# remotes handled by another helper)
|
||||
#
|
||||
# Backward compatibility is preserved for exactly one case: a host with no fleet
|
||||
# and no identity requested still gets the shared account, because on such a host
|
||||
# the shared account is the operator's own and there is no attribution to lose.
|
||||
# A host that HAS a fleet has agents whose records must be distinguishable, so
|
||||
# the shared fallback is refused there.
|
||||
#
|
||||
# A token is never written to stderr, to the escalation record, or to any log.
|
||||
# The implementation file (git-credential-mosaic.impl) refuses to run without
|
||||
# the clean-mode marker, so it cannot be invoked directly as a shaped-entry
|
||||
# bypass of this wrapper.
|
||||
|
||||
[ "$1" = "get" ] || exit 0
|
||||
import os
|
||||
import sys
|
||||
|
||||
host=""; username_in=""
|
||||
while IFS= read -r line; do
|
||||
[ -z "$line" ] && break
|
||||
case "$line" in
|
||||
host=*) host=${line#host=};;
|
||||
username=*) username_in=${line#username=};;
|
||||
esac
|
||||
done
|
||||
IMPL = os.path.join(os.path.dirname(os.path.realpath(__file__)), "git-credential-mosaic.impl")
|
||||
# Absolute-path candidates ONLY — never PATH resolution (an attacker-shaped
|
||||
# PATH must not choose the interpreter). /usr/bin/bash is the fleet-host
|
||||
# layout; /bin/bash is alpine and other FHS variants (found by the T125
|
||||
# gateway-image verification: the hardcoded /usr/bin/bash made every call
|
||||
# exit 127 inside node:22-alpine).
|
||||
BASH_CANDIDATES = ("/usr/bin/bash", "/bin/bash")
|
||||
BASH = next((p for p in BASH_CANDIDATES if os.access(p, os.X_OK)), None)
|
||||
|
||||
# Recognized Gitea hosts carry the per-identity token scheme. Anything else is
|
||||
# declined quietly — another helper owns it, and refusing would break it.
|
||||
case "$host" in
|
||||
git.uscllc.com) idpfx=gitea-usc;;
|
||||
git.mosaicstack.dev) idpfx=gitea-mosaicstack;;
|
||||
*) exit 0;;
|
||||
esac
|
||||
# Allowlist: everything else in the environment dies at this boundary. Adding
|
||||
# a variable here is a security decision — it crosses into a shell that no
|
||||
# longer has any startup shaping, but it also becomes the only context the
|
||||
# implementation can see.
|
||||
KEEP = (
|
||||
"HOME",
|
||||
"PATH",
|
||||
"LANG",
|
||||
"MOSAIC_GIT_IDENTITY",
|
||||
"MOSAIC_AGENT_NAME",
|
||||
"MOSAIC_BRAIN_HOME",
|
||||
"MOSAIC_CREDENTIAL_SPOOL",
|
||||
"MOSAIC_CREDENTIAL_LINEAGE_FENCE",
|
||||
)
|
||||
|
||||
ident="$MOSAIC_GIT_IDENTITY"; ident_src="MOSAIC_GIT_IDENTITY"
|
||||
if [ -z "$ident" ]; then
|
||||
ident=$(git config --get mosaic.gitIdentity 2>/dev/null)
|
||||
ident_src="git config mosaic.gitIdentity"
|
||||
fi
|
||||
if [ -z "$ident" ]; then
|
||||
ident="$username_in"
|
||||
ident_src="the username git supplied"
|
||||
fi
|
||||
env = {"_MOSAIC_HELPER_CLEAN": "1"}
|
||||
for name in KEEP:
|
||||
value = os.environ.get(name)
|
||||
if value is not None:
|
||||
env[name] = value
|
||||
|
||||
# ── Credential store selection ────────────────────────────────────────────────
|
||||
# An identity is a SEAT or it is a SERVICE, and which one it is determines where
|
||||
# its credential lives. There is no precedence rule between the two stores and no
|
||||
# fallback from one to the other: a seat whose slot is empty fails closed rather
|
||||
# than reading a service credential that happens to share its name.
|
||||
#
|
||||
# seat — <brain>/fleet/agents/<ident>/ exists
|
||||
# credential at <brain>/fleet/agents/<ident>/secrets/<idpfx>-<ident>.token
|
||||
# service — it does not
|
||||
# credential at ~/.config/mosaic/secrets/gitea-tokens/<idpfx>-<ident>.token
|
||||
#
|
||||
# One credential, one location. Two copies of one credential diverge, and the
|
||||
# stale copy fails in a way that reads as a revoked token rather than as drift.
|
||||
#
|
||||
# Brain-home resolution mirrors packages/mosaic/src/fleet/brain-home.ts and
|
||||
# tools/fleet/start-agent-session.sh: MOSAIC_BRAIN_HOME wins, else ~/.mosaic.
|
||||
brain_home="${MOSAIC_BRAIN_HOME:-$HOME/.mosaic}"
|
||||
svc_store="$HOME/.config/mosaic/secrets/gitea-tokens"
|
||||
|
||||
idtok=""; ident_kind=""
|
||||
if [ -n "$ident" ]; then
|
||||
if [ -d "$brain_home/fleet/agents/$ident" ]; then
|
||||
ident_kind="seat"
|
||||
idtok="$brain_home/fleet/agents/$ident/secrets/${idpfx}-${ident}.token"
|
||||
else
|
||||
ident_kind="service identity"
|
||||
idtok="$svc_store/${idpfx}-${ident}.token"
|
||||
fi
|
||||
if [ -r "$idtok" ]; then
|
||||
echo "username=${ident}"
|
||||
echo "password=$(cat "$idtok")"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Shared-account fallback: ONLY on a host with no fleet and no identity ──────
|
||||
# `fleet/agents` existing is the same signal brain-home.ts uses to decide a brain
|
||||
# is active. Where there are seats, records must be attributable, so an
|
||||
# unresolvable request is refused instead of borrowing the shared account.
|
||||
fleet_present=0
|
||||
[ -d "$brain_home/fleet/agents" ] && fleet_present=1
|
||||
|
||||
if [ -z "$ident" ] && [ "$fleet_present" -eq 0 ]; then
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=../_lib/credentials.sh
|
||||
source "$script_dir/../_lib/credentials.sh"
|
||||
load_credentials "$idpfx" >/dev/null 2>&1 || exit 0
|
||||
# GITEA_USER is not populated by load_credentials (it exports GITEA_URL and
|
||||
# GITEA_TOKEN only). Gitea's git-over-HTTP auth authenticates from the token in
|
||||
# the password field, not from the username string, so any non-empty
|
||||
# placeholder works — deliberately NOT a real account name, since framework
|
||||
# files stay operator-agnostic (tools/quality/scripts/verify-sanitized.sh).
|
||||
echo "username=${GITEA_USER:-git}"
|
||||
echo "password=$GITEA_TOKEN"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── FAIL CLOSED ───────────────────────────────────────────────────────────────
|
||||
if [ -z "$ident" ]; then
|
||||
reason="no-identity"
|
||||
else
|
||||
reason="no-token-for-identity"
|
||||
fi
|
||||
|
||||
seat="${MOSAIC_AGENT_NAME:-unknown}"
|
||||
ts=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
|
||||
# The escalation RECORD is durable and unconditional; any notification built on
|
||||
# top of it is best-effort. Record and alert are deduplicated separately — a cap
|
||||
# on the alert alone lets the spool grow without bound exactly while the operator
|
||||
# is being told nothing, so the louder the failure the quieter it gets.
|
||||
#
|
||||
# A record field is arbitrary operator-supplied text: an identity comes from git
|
||||
# config or the environment, and cwd is whatever directory git ran in. Either can
|
||||
# contain a quote or a backslash, which would make the line unparseable JSON --
|
||||
# and a spool that silently stops parsing is worse than no spool, because the
|
||||
# operator only discovers it while reading the record that explains an outage.
|
||||
json_escape() {
|
||||
local s=$1
|
||||
s=${s//\\/\\\\}
|
||||
s=${s//\"/\\\"}
|
||||
s=${s//$'\t'/\\t}
|
||||
s=${s//$'\r'/\\r}
|
||||
s=${s//$'\n'/\\n}
|
||||
printf '%s' "$s"
|
||||
}
|
||||
|
||||
spool="${MOSAIC_CREDENTIAL_SPOOL:-$HOME/.local/state/mosaic-credential-escalations}"
|
||||
spool_record=""
|
||||
if mkdir -p "$spool" 2>/dev/null; then
|
||||
chmod 700 "$spool" 2>/dev/null
|
||||
spoolfile="$spool/$(date -u +%Y%m%d).jsonl"
|
||||
dedupe="$spool/.spooled-${seat}-${ident:-none}-${reason}-$(date -u +%Y%m%d%H%M)"
|
||||
if [ ! -e "$dedupe" ]; then
|
||||
: > "$dedupe" 2>/dev/null
|
||||
printf '{"ts":"%s","reason":"%s","identity":"%s","identity_source":"%s","kind":"%s","seat":"%s","host":"%s","cwd":"%s"}\n' \
|
||||
"$(json_escape "$ts")" "$(json_escape "$reason")" \
|
||||
"$(json_escape "${ident:-<unset>}")" "$(json_escape "$ident_src")" \
|
||||
"$(json_escape "${ident_kind:-none}")" "$(json_escape "$seat")" \
|
||||
"$(json_escape "$host")" "$(json_escape "$PWD")" \
|
||||
>> "$spoolfile" 2>/dev/null
|
||||
chmod 600 "$spoolfile" 2>/dev/null
|
||||
fi
|
||||
# Name the record only if one is actually on disk. Printing the path
|
||||
# unconditionally sends the operator to a file that does not exist on exactly
|
||||
# the hosts where the spool could not be created.
|
||||
[ -s "$spoolfile" ] && spool_record="$spoolfile"
|
||||
find "$spool" -maxdepth 1 -name '.spooled-*' -mmin +120 -delete 2>/dev/null
|
||||
fi
|
||||
|
||||
cat >&2 <<EOF
|
||||
git-credential-mosaic: REFUSED (fail-closed).
|
||||
host : ${host}
|
||||
identity : ${ident:-<unset>}${ident:+ (from ${ident_src}; resolved as a ${ident_kind})}
|
||||
reason : ${reason}
|
||||
EOF
|
||||
|
||||
if [ -n "$ident" ]; then
|
||||
cat >&2 <<EOF
|
||||
expected : ${idtok}
|
||||
EOF
|
||||
fi
|
||||
|
||||
cat >&2 <<EOF
|
||||
|
||||
No per-identity credential resolved. This helper does NOT fall back to the shared
|
||||
account: that fallback makes every record it creates attributable to one
|
||||
principal, which is unrecoverable once a pull request has merged under it.
|
||||
|
||||
Fix (pick one):
|
||||
export MOSAIC_GIT_IDENTITY=<agent-id> # process-scoped
|
||||
git config mosaic.gitIdentity <agent-id> # per-repo/worktree, persists
|
||||
Then provision that identity's credential at the path named above. An identity
|
||||
with a directory under \${MOSAIC_BRAIN_HOME:-\$HOME/.mosaic}/fleet/agents/ is a
|
||||
seat and is read ONLY from its own secrets/ slot; any other identity is read from
|
||||
~/.config/mosaic/secrets/gitea-tokens/. There is no fallback between the two.
|
||||
|
||||
If this identity legitimately needs git access and has none, ask the orchestrator
|
||||
to provision one.
|
||||
|
||||
EOF
|
||||
|
||||
if [ -n "$spool_record" ]; then
|
||||
echo " record: ${spool_record}" >&2
|
||||
else
|
||||
echo " record: NOT WRITTEN — spool unavailable at ${spool}" >&2
|
||||
fi
|
||||
exit 1
|
||||
argv = [BASH, IMPL] + sys.argv[1:]
|
||||
if BASH is None:
|
||||
sys.stderr.write("git-credential-mosaic: no executable bash at " + " or ".join(BASH_CANDIDATES) + "\n")
|
||||
sys.exit(127)
|
||||
try:
|
||||
os.execve(BASH, argv, env)
|
||||
except OSError as exc:
|
||||
sys.stderr.write(f"git-credential-mosaic: entrypoint exec failed: {exc}\n")
|
||||
sys.exit(127)
|
||||
|
||||
@@ -0,0 +1,480 @@
|
||||
#!/bin/bash
|
||||
# git-credential-mosaic — git credential helper. Resolves a Gitea token from the
|
||||
# Mosaic credential store at runtime so remote URLs never embed secrets.
|
||||
#
|
||||
# Install (one-time, per clone or globally):
|
||||
# git config credential.helper "$HOME/.config/mosaic/tools/git/git-credential-mosaic"
|
||||
#
|
||||
# Per-agent identity (Gate-16 author != reviewer separation):
|
||||
# git config mosaic.gitIdentity <agent-id> # per-worktree, persists on disk
|
||||
# # or: export MOSAIC_GIT_IDENTITY=<agent-id>
|
||||
#
|
||||
# ── WHY THIS FAILS CLOSED ──────────────────────────────────────────────────────
|
||||
# This helper used to end by emitting the shared account's token for any request
|
||||
# it could not resolve to an identity. A seat with no identity, or with an
|
||||
# identity whose token was never provisioned, therefore received the most
|
||||
# privileged credential configured on the host — silently, and indistinguishably
|
||||
# from correct operation. Every record it then created (commit, push, PR, review)
|
||||
# was attributed to that shared account, so author != reviewer separation was
|
||||
# unenforceable and the true actor was unrecoverable after the fact.
|
||||
#
|
||||
# Under-provisioning must fail loudly, not impersonate. A refused git operation
|
||||
# is recoverable in one command; a merged pull request attributed to the wrong
|
||||
# principal is not.
|
||||
#
|
||||
# ── CONTRACT ───────────────────────────────────────────────────────────────────
|
||||
# identity : MOSAIC_GIT_IDENTITY > git config mosaic.gitIdentity > the
|
||||
# username git supplies on stdin
|
||||
# ownership: a FLEET SEAT caller may resolve ONLY its own identity, where
|
||||
# the CALLER is established by process ANCESTRY, not by the
|
||||
# current environment: every ancestor's /proc/<pid>/environ is
|
||||
# frozen at exec, so a child can rewrite its own MOSAIC_AGENT_NAME
|
||||
# but can never make an ancestor disagree with what the launcher
|
||||
# gave it (P5-RM-006; the dual-variable override was measured by
|
||||
# rev-code-02 F1). An anonymous caller (no lineage, no consensus)
|
||||
# may resolve NOTHING on a fleet host — seat or service
|
||||
# (rev-code-02 F2). Non-fleet hosts keep the documented legacy
|
||||
# paths below.
|
||||
# perms : a slot whose mode lets group or other read it (anything but
|
||||
# ?00) is refused — a loose slot is provisioning drift, and
|
||||
# serving from it silently widens every seat's exposure on a
|
||||
# single-account host.
|
||||
# store : chosen by what the identity IS, with no precedence and no
|
||||
# cross-store fallback (see "Credential store selection" below)
|
||||
# hit : emit username + password, exit 0
|
||||
# miss : emit NOTHING, spool a durable escalation record, explain on
|
||||
# stderr, exit 1 — git surfaces the failure and nothing is attributed
|
||||
# unknown host : exit 0 with no output, no record (passthrough for non-Mosaic
|
||||
# remotes handled by another helper)
|
||||
#
|
||||
# Backward compatibility is preserved for exactly one case: a host with no fleet
|
||||
# and no identity requested still gets the shared account, because on such a host
|
||||
# the shared account is the operator's own and there is no attribution to lose.
|
||||
# A host that HAS a fleet has agents whose records must be distinguishable, so
|
||||
# the shared fallback is refused there.
|
||||
#
|
||||
# A token is never written to stderr, to the escalation record, or to any log.
|
||||
|
||||
[ "$1" = "get" ] || exit 0
|
||||
|
||||
|
||||
# ── The shared refusal path ──────────────────────────────────────────────────
|
||||
# Every fail-closed exit funnels through refuse(): a durable escalation record
|
||||
# (deduped, JSON-escaped), a stderr diagnostic naming host/identity/reason,
|
||||
# caller-supplied guidance when the refusing site has specific advice, exit 1.
|
||||
# Defined here because the ownership gate below must be able to reach it.
|
||||
refuse() {
|
||||
local guidance="${1:-}"
|
||||
local seat ts spool spool_record spoolfile dedupe
|
||||
seat="${MOSAIC_AGENT_NAME:-unknown}"
|
||||
ts=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
|
||||
# A record field is arbitrary operator-supplied text: an identity comes from git
|
||||
# config or the environment, and cwd is whatever directory git ran in. Either can
|
||||
# contain a quote or a backslash, which would make the line unparseable JSON --
|
||||
# and a spool that silently stops parsing is worse than no spool, because the
|
||||
# operator only discovers it while reading the record that explains an outage.
|
||||
json_escape() {
|
||||
local s=$1
|
||||
s=${s//\\/\\\\}
|
||||
s=${s//\"/\\\"}
|
||||
s=${s//$'\t'/\\t}
|
||||
s=${s//$'\r'/\\r}
|
||||
s=${s//$'\n'/\\n}
|
||||
printf '%s' "$s"
|
||||
}
|
||||
|
||||
spool="${MOSAIC_CREDENTIAL_SPOOL:-$HOME/.local/state/mosaic-credential-escalations}"
|
||||
spool_record=""
|
||||
if mkdir -p "$spool" 2>/dev/null; then
|
||||
chmod 700 "$spool" 2>/dev/null
|
||||
spoolfile="$spool/$(date -u +%Y%m%d).jsonl"
|
||||
dedupe="$spool/.spooled-${seat}-${ident:-none}-${reason}-$(date -u +%Y%m%d%H%M)"
|
||||
if [ ! -e "$dedupe" ]; then
|
||||
: > "$dedupe" 2>/dev/null
|
||||
printf '{"ts":"%s","reason":"%s","identity":"%s","identity_source":"%s","kind":"%s","seat":"%s","host":"%s","cwd":"%s"}\n' \
|
||||
"$(json_escape "$ts")" "$(json_escape "$reason")" \
|
||||
"$(json_escape "${ident:-<unset>}")" "$(json_escape "$ident_src")" \
|
||||
"$(json_escape "${ident_kind:-none}")" "$(json_escape "$seat")" \
|
||||
"$(json_escape "$host")" "$(json_escape "$PWD")" \
|
||||
>> "$spoolfile" 2>/dev/null
|
||||
chmod 600 "$spoolfile" 2>/dev/null
|
||||
fi
|
||||
# Name the record only if one is actually on disk. Printing the path
|
||||
# unconditionally sends the operator to a file that does not exist on exactly
|
||||
# the hosts where the spool could not be created.
|
||||
[ -s "$spoolfile" ] && spool_record="$spoolfile"
|
||||
find "$spool" -maxdepth 1 -name '.spooled-*' -mmin +120 -delete 2>/dev/null
|
||||
fi
|
||||
|
||||
while IFS= builtin read -r _diag_line; do builtin printf '%s\n' "$_diag_line" >&2; done <<EOF
|
||||
git-credential-mosaic: REFUSED (fail-closed).
|
||||
host : ${host}
|
||||
identity : ${ident:-<unset>}${ident:+ (from ${ident_src}; resolved as a ${ident_kind})}
|
||||
reason : ${reason}
|
||||
EOF
|
||||
|
||||
if [ -n "$ident" ]; then
|
||||
while IFS= builtin read -r _diag_line; do builtin printf '%s\n' "$_diag_line" >&2; done <<EOF
|
||||
expected : ${idtok}
|
||||
EOF
|
||||
fi
|
||||
|
||||
while IFS= builtin read -r _diag_line; do builtin printf '%s\n' "$_diag_line" >&2; done <<EOF
|
||||
|
||||
${guidance}
|
||||
EOF
|
||||
|
||||
if [ -n "$spool_record" ]; then
|
||||
echo " record: ${spool_record}" >&2
|
||||
else
|
||||
echo " record: NOT WRITTEN — spool unavailable at ${spool}" >&2
|
||||
fi
|
||||
exit 1
|
||||
}
|
||||
|
||||
# ── Bash environment injection guard (rev-code-02 R3, B1) ───────────────────
|
||||
# Non-interactive bash sources $BASH_ENV at startup and imports exported
|
||||
# functions from BASH_FUNC_* environment entries; either can define a read()
|
||||
# or printf() that shadows the builtin the ancestry walker and diagnostics
|
||||
# rely on — measured live by the reviewer's fixture (BASH_ENV read() rewrote
|
||||
# every ancestry entry). A legitimate fleet seat environment carries neither
|
||||
# (verified: zero BASH_FUNC_* in seat envs), so their presence in a helper
|
||||
# request is an injection attempt: scrub the shadows first (so even the
|
||||
# refusal machinery cannot be subverted), then refuse fail-closed.
|
||||
# Imported functions are detected by ENUMERATION, not env-var names: bash
|
||||
# consumes BASH_FUNC_* variables while importing the functions, so the
|
||||
# environment no longer shows them (measured). At this point the script has
|
||||
# defined exactly one function of its own (refuse); anything else in the
|
||||
# function table arrived from the caller's environment. BASH_ENV is checked
|
||||
# directly (it remains visible after sourcing).
|
||||
_injected=0
|
||||
_inj_names=""
|
||||
while IFS=' ' builtin read -r _decl _kind _fn; do
|
||||
[ -n "$_fn" ] || continue
|
||||
case "$_fn" in
|
||||
refuse) ;;
|
||||
*) _injected=1; _inj_names="$_inj_names $_fn";;
|
||||
esac
|
||||
done < <(declare -F)
|
||||
_inj_vars="${!BASH_FUNC_@}"
|
||||
if [ -n "$_inj_vars" ]; then
|
||||
_injected=1
|
||||
for _iv in $_inj_vars; do
|
||||
case "$_iv" in
|
||||
BASH_FUNC_*%%) _ifn="${_iv#BASH_FUNC_}"; _ifn="${_ifn%%%}";;
|
||||
BASH_FUNC_*) _ifn="${_iv#BASH_FUNC_}";;
|
||||
*) _ifn="";;
|
||||
esac
|
||||
[ -n "$_ifn" ] && { unset -f "$_ifn" 2>/dev/null; _inj_names="$_inj_names $_ifn"; }
|
||||
done
|
||||
fi
|
||||
if [ "$_injected" = 1 ] || [ -n "${BASH_ENV:-}" ]; then
|
||||
while IFS=' ' builtin read -r _decl _kind _fn; do
|
||||
[ "$_fn" = refuse ] || unset -f "$_fn" 2>/dev/null
|
||||
done < <(declare -F)
|
||||
unset BASH_ENV 2>/dev/null
|
||||
reason="bash-environment-injection-refused"
|
||||
refuse "The helper's bash startup state was externally shaped: BASH_ENV is
|
||||
set and/or exported BASH_FUNC_* functions are present in the request
|
||||
environment. Non-interactive bash sources BASH_ENV and imports those
|
||||
functions BEFORE any script line runs, so builtins this helper's security
|
||||
decisions rely on could be shadowed. Nothing resolves from a shaped request
|
||||
environment. If this surprised a legitimate workflow, the caller environment
|
||||
must be cleaned (no BASH_ENV, no exported functions) before invoking git."
|
||||
fi
|
||||
|
||||
|
||||
host=""; username_in=""
|
||||
while IFS= builtin read -r line; do
|
||||
[ -z "$line" ] && break
|
||||
case "$line" in
|
||||
host=*) host=${line#host=};;
|
||||
username=*) username_in=${line#username=};;
|
||||
esac
|
||||
done
|
||||
|
||||
# Recognized Gitea hosts carry the per-identity token scheme. Anything else is
|
||||
# declined quietly — another helper owns it, and refusing would break it.
|
||||
case "$host" in
|
||||
git.uscllc.com) idpfx=gitea-usc;;
|
||||
git.mosaicstack.dev) idpfx=gitea-mosaicstack;;
|
||||
*) exit 0;;
|
||||
esac
|
||||
|
||||
# ── Clean-entrypoint assert (P0-SEC R4) ─────────────────────────────────────
|
||||
# This implementation only runs behind the python entrypoint
|
||||
# (git-credential-mosaic), which execve's it with an allowlist environment:
|
||||
# no BASH_ENV, no imported functions, nothing shapable at bash startup. A
|
||||
# direct invocation without the marker is a bypass attempt on that boundary
|
||||
# and refuses. Placed after refuse() and the host parse so the refusal path
|
||||
# exists when it fires (an earlier placement died on 'refuse: command not
|
||||
# found' — the failure mode is real, keep this after every definition it
|
||||
# calls).
|
||||
if [ "${_MOSAIC_HELPER_CLEAN:-}" != "1" ]; then
|
||||
reason="direct-entrypoint-refused"
|
||||
refuse "This implementation refuses to run outside the production
|
||||
entrypoint. git-credential-mosaic (the python wrapper in this directory)
|
||||
execve's it with a hand-built, unshapable environment; invoking the .impl
|
||||
directly bypasses that boundary. Credential requests go through git, which
|
||||
invokes the wrapper named in gitconfig."
|
||||
fi
|
||||
|
||||
ident="$MOSAIC_GIT_IDENTITY"; ident_src="MOSAIC_GIT_IDENTITY"
|
||||
if [ -z "$ident" ]; then
|
||||
ident=$(git config --get mosaic.gitIdentity 2>/dev/null)
|
||||
ident_src="git config mosaic.gitIdentity"
|
||||
fi
|
||||
if [ -z "$ident" ]; then
|
||||
ident="$username_in"
|
||||
ident_src="the username git supplied"
|
||||
fi
|
||||
|
||||
# ── Credential store selection ────────────────────────────────────────────────
|
||||
# An identity is a SEAT or it is a SERVICE, and which one it is determines where
|
||||
# its credential lives. There is no precedence rule between the two stores and no
|
||||
# fallback from one to the other: a seat whose slot is empty fails closed rather
|
||||
# than reading a service credential that happens to share its name.
|
||||
#
|
||||
# seat — <brain>/fleet/agents/<ident>/ exists
|
||||
# credential at <brain>/fleet/agents/<ident>/secrets/<idpfx>-<ident>.token
|
||||
# service — it does not
|
||||
# credential at ~/.config/mosaic/secrets/gitea-tokens/<idpfx>-<ident>.token
|
||||
#
|
||||
# One credential, one location. Two copies of one credential diverge, and the
|
||||
# stale copy fails in a way that reads as a revoked token rather than as drift.
|
||||
#
|
||||
# Brain-home resolution mirrors packages/mosaic/src/fleet/brain-home.ts and
|
||||
# tools/fleet/start-agent-session.sh: MOSAIC_BRAIN_HOME wins, else ~/.mosaic.
|
||||
brain_home="${MOSAIC_BRAIN_HOME:-$HOME/.mosaic}"
|
||||
svc_store="$HOME/.config/mosaic/secrets/gitea-tokens"
|
||||
|
||||
# ── Caller-identity ownership (P5-RM-006) ──────────────────────────────────────
|
||||
# A credential request is honourable only when the CALLER owns the identity it
|
||||
# asks for. On a fleet host every seat shares one unix account, so the
|
||||
# launcher-established MOSAIC_AGENT_NAME is the only attribution signal the
|
||||
# helper has. Two measured paths made the old contract unsafe:
|
||||
#
|
||||
# - a seat exporting MOSAIC_GIT_IDENTITY=<another-seat> resolved that seat's
|
||||
# token through the normal precedence chain (T97 G2, jarvis V2 probe), and
|
||||
# - an anonymous caller (no seat name) inherited the host gitconfig's
|
||||
# username=jarvis line and resolved jarvis's slot (T94: five watcher units
|
||||
# flapping on exactly this class).
|
||||
#
|
||||
# Ownership rules, fail-closed on fleet hosts only; a host with no fleet keeps
|
||||
# the legacy contract unchanged:
|
||||
# 1. a SEAT caller may resolve only its own identity;
|
||||
# 2. an anonymous caller may not resolve any SEAT identity (service
|
||||
# identities remain available to non-seat automation such as CI).
|
||||
# [P5-RM-006r1 ancestry binding begin]
|
||||
# ── Caller identity from exec-frozen ancestry (rev-code-02 F1/F2) ───────────
|
||||
# Walk /proc self->root collecting MOSAIC_AGENT_NAME from each ancestor's
|
||||
# frozen environ. Rules:
|
||||
# - any DISAGREEMENT (an ancestor value != the current value, or ancestors
|
||||
# disagreeing among themselves) is a rewrite -> spoof-refused, nothing
|
||||
# resolves. A child can inject variables downward but cannot alter an
|
||||
# ancestor's exec-frozen environ, so the launcher-established value always
|
||||
# participates in the comparison.
|
||||
# - consensus (all ancestors that carry the var agree with the current env,
|
||||
# or with each other when the current env is empty) -> caller = that value.
|
||||
# - no ancestor carries it -> the current claim is unlineaged: caller is
|
||||
# anonymous regardless of what the environment says. A name with no
|
||||
# lineage is a claim, not an identity.
|
||||
# The walk stops at PID 1, at a missing /proc entry, or INCLUSIVE at an
|
||||
# ancestor that carries MOSAIC_CREDENTIAL_LINEAGE_FENCE with an EMPTY agent
|
||||
# name — the test-suite lineage root. A fence beside a non-empty name is
|
||||
# IGNORED and the walk continues, so an attacker cannot fence off the true
|
||||
# ancestry by planting the marker next to a victim name.
|
||||
trusted_caller() {
|
||||
# PATH-HARDENED (rev-code-02 R1 F1): every /proc read below uses ONLY bash
|
||||
# builtins (read/case/parameter expansion). The first implementation piped
|
||||
# through PATH-resolved tr/sed/head/grep, and a caller that prepends hostile
|
||||
# utilities to PATH in the same invocation that overrides the identity
|
||||
# variables could forge the ancestry itself. Builtins cannot be shadowed.
|
||||
local pid ppid v entry line fence
|
||||
local -a vals=()
|
||||
pid=$$
|
||||
while :; do
|
||||
v=""
|
||||
fence=0
|
||||
if [ -r "/proc/$pid/environ" ]; then
|
||||
# Read inside a captured subshell whose stderr is closed: opening
|
||||
# /proc/<pid>/environ can fail with EACCES on ancestors that are
|
||||
# readable-by-mode but not openable (session managers), and that open
|
||||
# failure prints from the SHELL, immune to loop-level 2>/dev/null
|
||||
# (measured). The subshell makes the skip silent; NUL separators are
|
||||
# converted to newlines for the parent's builtin parse.
|
||||
_env_text=$( { while IFS= builtin read -r -d '' _e; do builtin printf '%s\n' "$_e"; done < "/proc/$pid/environ"; } 2>/dev/null )
|
||||
while IFS= builtin read -r entry; do
|
||||
[ -n "$entry" ] || continue
|
||||
case "$entry" in
|
||||
MOSAIC_AGENT_NAME=*) v="${entry#MOSAIC_AGENT_NAME=}";;
|
||||
MOSAIC_CREDENTIAL_LINEAGE_FENCE=*) fence=1;;
|
||||
esac
|
||||
done <<EOF_ENV
|
||||
$_env_text
|
||||
EOF_ENV
|
||||
fi
|
||||
if [ "$pid" != "$$" ]; then
|
||||
[ -n "$v" ] && vals+=("$v")
|
||||
if [ "$fence" = 1 ] && [ -z "$v" ]; then
|
||||
break
|
||||
fi
|
||||
fi
|
||||
ppid=""
|
||||
if [ -r "/proc/$pid/status" ]; then
|
||||
while IFS= builtin read -r line; do
|
||||
case "$line" in
|
||||
PPid:*) ppid="${line#PPid:}"; ppid="${ppid//[[:space:]]/}";;
|
||||
esac
|
||||
done < "/proc/$pid/status"
|
||||
fi
|
||||
case "$ppid" in ''|0|1) break;; esac
|
||||
pid=$ppid
|
||||
done
|
||||
local self="${MOSAIC_AGENT_NAME:-}" i consensus=""
|
||||
if [ "${#vals[@]}" -gt 0 ]; then
|
||||
consensus="${vals[0]}"
|
||||
for i in "${vals[@]}"; do
|
||||
if [ "$i" != "$consensus" ]; then
|
||||
printf 'SPOOF'
|
||||
return
|
||||
fi
|
||||
done
|
||||
if [ -n "$self" ] && [ "$self" != "$consensus" ]; then
|
||||
printf 'SPOOF'
|
||||
return
|
||||
fi
|
||||
fi
|
||||
printf '%s' "$consensus"
|
||||
}
|
||||
|
||||
if [ -d "$brain_home/fleet/agents" ]; then
|
||||
caller="$(trusted_caller)"
|
||||
if [ "$caller" = "SPOOF" ]; then
|
||||
reason="caller-identity-spoof-refused"
|
||||
refuse "The MOSAIC_AGENT_NAME lineage disagrees within this process tree:
|
||||
an ancestor established by exec carries a different value than the request.
|
||||
A child process can rewrite its own environment but never an ancestor's
|
||||
frozen environ, so disagreement is a rewrite, not a race. Nothing resolves
|
||||
under a rewritten caller identity. If this surprised a legitimate workflow,
|
||||
run git from the seat's own session, not from a rewritten environment."
|
||||
fi
|
||||
if [ -n "$caller" ] && [ -d "$brain_home/fleet/agents/$caller" ]; then
|
||||
if [ -n "$ident" ] && [ "$ident" != "$caller" ]; then
|
||||
reason="cross-seat-identity-refused"
|
||||
refuse "A seat may resolve only its own credential slot. Caller seat is
|
||||
'$caller' (ancestry-established); the request names '$ident'. Overriding
|
||||
MOSAIC_GIT_IDENTITY (or a git config / URL username) to another seat's name is
|
||||
exactly the path this refusal exists to close. If '$ident' auth is genuinely
|
||||
required, that seat runs the operation itself or the orchestrator provisions
|
||||
an explicit grant."
|
||||
fi
|
||||
else
|
||||
# Anonymous caller on a fleet host (no lineage, or the lineage root is not
|
||||
# a seat): NOTHING resolves — seat slots (T94 jarvis@ class) or legacy
|
||||
# service credentials (rev-code-02 F2: credentialed services are seats;
|
||||
# the legacy store is vestigial and not anonymously reachable).
|
||||
if [ -n "$ident" ]; then
|
||||
ident_kind="${ident_kind:-}"
|
||||
[ -d "$brain_home/fleet/agents/$ident" ] && ident_kind="seat" || ident_kind="service identity"
|
||||
reason="anonymous-credential-refused"
|
||||
refuse "This caller has no seat lineage on a fleet host and asked for
|
||||
'$ident' (a ${ident_kind}). Anonymous callers resolve nothing on fleet hosts:
|
||||
seat credentials must never serve an unattributable caller, and credentialed
|
||||
services are seats with their own sessions (the legacy service store is
|
||||
vestigial). Run from the owning seat's session."
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
# [P5-RM-006r1 ancestry binding end]
|
||||
|
||||
idtok=""; ident_kind=""
|
||||
if [ -n "$ident" ]; then
|
||||
if [ -d "$brain_home/fleet/agents/$ident" ]; then
|
||||
ident_kind="seat"
|
||||
idtok="$brain_home/fleet/agents/$ident/secrets/${idpfx}-${ident}.token"
|
||||
else
|
||||
ident_kind="service identity"
|
||||
idtok="$svc_store/${idpfx}-${ident}.token"
|
||||
fi
|
||||
if [ -r "$idtok" ]; then
|
||||
# P5-RM-006 seat permissions: a SEAT slot readable by group or other is
|
||||
# provisioning drift, and on a single-account fleet host it widens every
|
||||
# seat's exposure at once. Refuse rather than serve from a loose slot; the
|
||||
# record names the path so the provisioning fix is one chmod away.
|
||||
# Scoped to seat slots: the framework service store is operator-managed
|
||||
# and outside this work unit's permission surface.
|
||||
if [ "${ident_kind:-}" = "seat" ]; then
|
||||
# command -p resolves stat from the POSIX default PATH (system
|
||||
# directories), never the caller's PATH (rev-code-02 R3 B2: a shadowed
|
||||
# stat reported a 0644 slot as 600 and the helper served it). Output is
|
||||
# shape-validated: anything that is not 3-4 octal digits refuses.
|
||||
slot_mode="$(command -p stat -c '%a' "$idtok" 2>/dev/null || true)"
|
||||
case "$slot_mode" in
|
||||
[0-7][0-7][0-7]|[0-7][0-7][0-7][0-7]) ;;
|
||||
*) slot_mode="unverifiable";;
|
||||
esac
|
||||
if [ "${slot_mode:1:2}" != "00" ]; then
|
||||
reason="slot-permission-violation"
|
||||
refuse "Slot $idtok has mode ${slot_mode:-unknown}; expected owner-only
|
||||
(0600 or stricter). Tighten it: chmod 600 '$idtok'. This refusal is the seat
|
||||
permissions half of P5-RM-006: a loose slot on a shared-account host is every
|
||||
seat's exposure, so the helper declines to serve from it. Mode inspection uses
|
||||
command -p (trusted PATH) and fails closed on unverifiable output."
|
||||
fi
|
||||
fi
|
||||
echo "username=${ident}"
|
||||
echo "password=$(<"$idtok")"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Shared-account fallback: ONLY on a host with no fleet and no identity ──────
|
||||
# `fleet/agents` existing is the same signal brain-home.ts uses to decide a brain
|
||||
# is active. Where there are seats, records must be attributable, so an
|
||||
# unresolvable request is refused instead of borrowing the shared account.
|
||||
fleet_present=0
|
||||
[ -d "$brain_home/fleet/agents" ] && fleet_present=1
|
||||
|
||||
if [ -z "$ident" ] && [ "$fleet_present" -eq 0 ]; then
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=../_lib/credentials.sh
|
||||
source "$script_dir/../_lib/credentials.sh"
|
||||
load_credentials "$idpfx" >/dev/null 2>&1 || exit 0
|
||||
# GITEA_USER is not populated by load_credentials (it exports GITEA_URL and
|
||||
# GITEA_TOKEN only). Gitea's git-over-HTTP auth authenticates from the token in
|
||||
# the password field, not from the username string, so any non-empty
|
||||
# placeholder works — deliberately NOT a real account name, since framework
|
||||
# files stay operator-agnostic (tools/quality/scripts/verify-sanitized.sh).
|
||||
echo "username=${GITEA_USER:-git}"
|
||||
echo "password=$GITEA_TOKEN"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── FAIL CLOSED ───────────────────────────────────────────────────────────────
|
||||
# The escalation RECORD is durable and unconditional; any notification built on
|
||||
# top of it is best-effort (see refuse()). Record and alert are deduplicated
|
||||
# separately — a cap on the alert alone lets the spool grow without bound
|
||||
# exactly while the operator is being told nothing, so the louder the failure
|
||||
# the quieter it gets.
|
||||
if [ -z "$ident" ]; then
|
||||
reason="no-identity"
|
||||
else
|
||||
reason="no-token-for-identity"
|
||||
fi
|
||||
refuse "No per-identity credential resolved. This helper does NOT fall back to the shared
|
||||
account: that fallback makes every record it creates attributable to one
|
||||
principal, which is unrecoverable once a pull request has merged under it.
|
||||
|
||||
Fix (pick one):
|
||||
export MOSAIC_GIT_IDENTITY=<agent-id> # process-scoped
|
||||
git config mosaic.gitIdentity <agent-id> # per-repo/worktree, persists
|
||||
Then provision that identity's credential at the path named above. An identity
|
||||
with a directory under \${MOSAIC_BRAIN_HOME:-\$HOME/.mosaic}/fleet/agents/ is a
|
||||
seat and is read ONLY from its own secrets/ slot; any other identity is read from
|
||||
~/.config/mosaic/secrets/gitea-tokens/. There is no fallback between the two.
|
||||
|
||||
If this identity legitimately needs git access and has none, ask the orchestrator
|
||||
to provision one."
|
||||
|
||||
@@ -34,20 +34,23 @@ REPO_DIR="$WORK_DIR/repo"
|
||||
BRAIN_DIR="$WORK_DIR/brain"
|
||||
SPOOL_DIR="$WORK_DIR/spool"
|
||||
SVC_STORE="$FAKE_HOME/.config/mosaic/secrets/gitea-tokens"
|
||||
# Mirror the real deployed layout (~/.config/mosaic/tools/{git,_lib}/) under the
|
||||
# Mirror the real deployed layout (~/.mosaic/tools/{git,_lib}/) under the
|
||||
# fake HOME: git-credential-mosaic resolves its credentials.sh sibling via a
|
||||
# script-relative path (BASH_SOURCE), so the copy must live next to a stubbed
|
||||
# _lib/credentials.sh, not the real one, to keep this test hermetic.
|
||||
HELPER="$FAKE_HOME/.config/mosaic/tools/git/git-credential-mosaic"
|
||||
HELPER="$FAKE_HOME/.mosaic/tools/git/git-credential-mosaic"
|
||||
IMPL="$FAKE_HOME/.mosaic/tools/git/git-credential-mosaic.impl"
|
||||
|
||||
rm -rf "$WORK_DIR"
|
||||
mkdir -p "$SVC_STORE" \
|
||||
"$FAKE_HOME/.config/mosaic/tools/git" \
|
||||
"$FAKE_HOME/.config/mosaic/tools/_lib" \
|
||||
"$FAKE_HOME/.mosaic/tools/git" \
|
||||
"$FAKE_HOME/.mosaic/tools/_lib" \
|
||||
"$REPO_DIR" "$BRAIN_DIR"
|
||||
|
||||
cp "$SCRIPT_DIR/git-credential-mosaic" "$HELPER"
|
||||
chmod +x "$HELPER"
|
||||
cp "$SCRIPT_DIR/git-credential-mosaic.impl" "$IMPL"
|
||||
chmod +x "$IMPL"
|
||||
|
||||
git -C "$REPO_DIR" init -q
|
||||
git -C "$REPO_DIR" config user.email "[email protected]"
|
||||
@@ -55,7 +58,7 @@ git -C "$REPO_DIR" config user.name "Test"
|
||||
|
||||
# Fake shared-account credential loader — stands in for
|
||||
# tools/_lib/credentials.sh's load_credentials(), scoped to this test only.
|
||||
cat > "$FAKE_HOME/.config/mosaic/tools/_lib/credentials.sh" <<'SH'
|
||||
cat > "$FAKE_HOME/.mosaic/tools/_lib/credentials.sh" <<'SH'
|
||||
load_credentials() {
|
||||
case "$1" in
|
||||
gitea-mosaicstack) GITEA_URL="https://git.mosaicstack.dev"; GITEA_TOKEN="shared-mosaicstack-token"; export GITEA_URL GITEA_TOKEN; return 0 ;;
|
||||
@@ -82,7 +85,7 @@ run_helper() {
|
||||
(
|
||||
cd "$REPO_DIR"
|
||||
env -i HOME="$FAKE_HOME" PATH="$PATH" MOSAIC_CREDENTIAL_SPOOL="$SPOOL_DIR" "$@" \
|
||||
bash "$HELPER" get <<EOF
|
||||
"$HELPER" get <<EOF
|
||||
host=$host
|
||||
username=$username_in
|
||||
|
||||
@@ -90,6 +93,80 @@ EOF
|
||||
)
|
||||
}
|
||||
|
||||
# ── Lineage harness (rev-code-02 F1/F2 rework) ─────────────────────────────
|
||||
# Establishes a seat CALLER the way production does — frozen into an
|
||||
# ancestor's exec environment — instead of injecting MOSAIC_AGENT_NAME into
|
||||
# the helper's own env. Two scripts are generated into WORK_DIR:
|
||||
#
|
||||
# lineage-root.sh (pid A): invoked with a fence marker and NO agent name.
|
||||
# With a non-empty caller arg it forks the carrier (pid C); with an
|
||||
# empty caller it forks the helper directly (deterministic anonymous
|
||||
# lineage even when the suite itself runs inside a seat).
|
||||
# lineage-carrier.sh (pid C): MOSAIC_AGENT_NAME=<caller> frozen at exec;
|
||||
# forks the helper (pid D) with a fully controlled env.
|
||||
#
|
||||
# The helper's walk then sees exactly: self -> C(caller) or D-direct ->
|
||||
# A(fence, empty name -> stop). EXTRA assignments ride pid D's environment
|
||||
# (that is where a rewrite would live — which is the point of the F1 arms).
|
||||
cat > "$WORK_DIR/lineage-root.sh" <<'LINROOT'
|
||||
#!/usr/bin/env bash
|
||||
# pid A — lineage root. Args: <caller> <carrier-script> <helper> <spool>
|
||||
# <brain> <repo> [extra KEY=VALUE...]
|
||||
set -u
|
||||
caller="$1"; carrier="$2"; helper="$3"; spool="$4"; brain="$5"; repo="$6"; shift 6
|
||||
if [ -n "$caller" ]; then
|
||||
env MOSAIC_AGENT_NAME="$caller" PATH="$PATH" HOME="$HOME" \
|
||||
bash "$carrier" "$helper" "$spool" "$brain" "$repo" "$@"
|
||||
else
|
||||
env -i HOME="$HOME" PATH="$PATH" MOSAIC_CREDENTIAL_SPOOL="$spool" \
|
||||
MOSAIC_BRAIN_HOME="$brain" "$@" "$helper" get
|
||||
fi
|
||||
LINROOT
|
||||
cat > "$WORK_DIR/lineage-carrier.sh" <<'LINCARR'
|
||||
#!/usr/bin/env bash
|
||||
# pid C — the caller's frozen environment. Forks the helper (pid D).
|
||||
set -u
|
||||
helper="$1"; spool="$2"; brain="$3"; repo="$4"; shift 4
|
||||
cd "$repo"
|
||||
env -i HOME="$HOME" PATH="$PATH" MOSAIC_CREDENTIAL_SPOOL="$spool" \
|
||||
MOSAIC_BRAIN_HOME="$brain" "$@" "$helper" get
|
||||
LINCARR
|
||||
chmod +x "$WORK_DIR/lineage-root.sh" "$WORK_DIR/lineage-carrier.sh"
|
||||
|
||||
run_lineage() {
|
||||
# run_lineage <caller|empty-for-anonymous> [helper-env KEY=VALUE...]
|
||||
local caller="$1"; shift
|
||||
printf 'host=git.mosaicstack.dev\nusername=probe\n\n' | \
|
||||
env -i HOME="$FAKE_HOME" PATH="$PATH" MOSAIC_CREDENTIAL_LINEAGE_FENCE=1 \
|
||||
bash "$WORK_DIR/lineage-root.sh" "$caller" "$WORK_DIR/lineage-carrier.sh" \
|
||||
"$HELPER" "$SPOOL_DIR" "$BRAIN_DIR" "$REPO_DIR" "$@"
|
||||
}
|
||||
|
||||
assert_refused_lineage() {
|
||||
# assert_refused_lineage <desc> <caller> <want-reason> [helper-env...]
|
||||
local desc="$1" caller="$2" want="$3"; shift 3
|
||||
local stderr_file="$WORK_DIR/stderr-lin.tmp" rc stdout
|
||||
: > "$stderr_file"
|
||||
set +e
|
||||
stdout=$(run_lineage "$caller" "$@" 2>"$stderr_file")
|
||||
rc=$?
|
||||
set -e
|
||||
local stderr; stderr=$(cat "$stderr_file")
|
||||
if [[ "$rc" -eq 0 ]]; then
|
||||
echo "FAIL: $desc — expected nonzero exit, got 0 (stdout='$stdout')" >&2; fail=1
|
||||
fi
|
||||
if [[ -n "$stdout" ]]; then
|
||||
echo "FAIL: $desc — expected empty stdout, got '$stdout'" >&2; fail=1
|
||||
fi
|
||||
if [[ -n "$want" && "$stderr" != *"$want"* ]]; then
|
||||
echo "FAIL: $desc — stderr lacks '$want':" >&2; echo "$stderr" >&2; fail=1
|
||||
fi
|
||||
if [[ "$stdout$stderr" == *"seatG-slot-token"* || "$stdout$stderr" == *"seatE-slot-token"* \
|
||||
|| "$stdout$stderr" == *"shared-mosaicstack-token"* || "$stdout$stderr" == *"shared-usc-token"* ]]; then
|
||||
echo "FAIL: $desc — a slot or shared token VALUE appeared in output" >&2; fail=1
|
||||
fi
|
||||
}
|
||||
|
||||
# A refusal must be observable in four independent ways: nonzero exit, EMPTY
|
||||
# stdout, a stderr diagnostic naming the identity and host, and — the assertion
|
||||
# that actually catches a regression to the old behavior — NO shared token value
|
||||
@@ -222,7 +299,10 @@ fi
|
||||
# ---------------------------------------------------------------------------
|
||||
mkdir -p "$BRAIN_DIR/fleet/agents/seatE/secrets"
|
||||
echo -n "seatE-slot-token" > "$BRAIN_DIR/fleet/agents/seatE/secrets/gitea-mosaicstack-seatE.token"
|
||||
out=$(run_helper "git.mosaicstack.dev" "seatE" MOSAIC_BRAIN_HOME="$BRAIN_DIR")
|
||||
chmod 600 "$BRAIN_DIR/fleet/agents/seatE/secrets/gitea-mosaicstack-seatE.token"
|
||||
# Seat arms run through the lineage harness below (rev-code-02 F1/F2 rework):
|
||||
# a seat caller must be established by ancestry, not by the helper's own env.
|
||||
out=$(run_lineage seatE MOSAIC_AGENT_NAME=seatE MOSAIC_GIT_IDENTITY=seatE)
|
||||
assert_eq "seat reads its own slot: username" "username=seatE" "$(echo "$out" | grep '^username=')"
|
||||
assert_eq "seat reads its own slot: password" "password=seatE-slot-token" "$(echo "$out" | grep '^password=')"
|
||||
|
||||
@@ -236,8 +316,8 @@ assert_eq "seat reads its own slot: password" "password=seatE-slot-token" "$(ech
|
||||
# ---------------------------------------------------------------------------
|
||||
mkdir -p "$BRAIN_DIR/fleet/agents/seatF/secrets"
|
||||
echo -n "seatF-SERVICE-STORE-token" > "$SVC_STORE/gitea-mosaicstack-seatF.token"
|
||||
assert_fail_closed "seat with empty slot does NOT fall back to the framework store" \
|
||||
"git.mosaicstack.dev" "seatF" "fleet/agents/seatF/secrets" MOSAIC_BRAIN_HOME="$BRAIN_DIR"
|
||||
assert_refused_lineage "seat with empty slot does NOT fall back to the framework store" \
|
||||
seatF no-token-for-identity MOSAIC_AGENT_NAME=seatF MOSAIC_GIT_IDENTITY=seatF
|
||||
: > "$WORK_DIR/stderr.tmp"
|
||||
set +e
|
||||
xstore_out=$(run_helper "git.mosaicstack.dev" "seatF" MOSAIC_BRAIN_HOME="$BRAIN_DIR" 2>"$WORK_DIR/stderr.tmp")
|
||||
@@ -288,7 +368,7 @@ assert_eq "unknown host on a fleet host: still passthrough, not a refusal" "" "$
|
||||
# 13. Non-"get" verb (store/erase) -> exit 0, no output (git-credential
|
||||
# protocol: this helper only implements get).
|
||||
# ---------------------------------------------------------------------------
|
||||
store_out=$(cd "$REPO_DIR" && env -i HOME="$FAKE_HOME" PATH="$PATH" bash "$HELPER" store <<EOF
|
||||
store_out=$(cd "$REPO_DIR" && env -i HOME="$FAKE_HOME" PATH="$PATH" "$HELPER" store <<EOF
|
||||
host=git.mosaicstack.dev
|
||||
username=no-such-agent
|
||||
password=whatever
|
||||
@@ -310,7 +390,7 @@ hostile_spool="$WORK_DIR/spool-hostile"
|
||||
cd "$hostile_dir"
|
||||
env -i HOME="$FAKE_HOME" PATH="$PATH" MOSAIC_CREDENTIAL_SPOOL="$hostile_spool" \
|
||||
MOSAIC_GIT_IDENTITY=no-such-agent \
|
||||
bash "$HELPER" get <<EOF >/dev/null 2>&1
|
||||
"$HELPER" get <<EOF >/dev/null 2>&1
|
||||
host=git.mosaicstack.dev
|
||||
username=no-such-agent
|
||||
|
||||
@@ -349,7 +429,7 @@ nospool_err=$(
|
||||
cd "$REPO_DIR"
|
||||
env -i HOME="$FAKE_HOME" PATH="$PATH" MOSAIC_CREDENTIAL_SPOOL="$unwritable_spool" \
|
||||
MOSAIC_GIT_IDENTITY=no-such-agent \
|
||||
bash "$HELPER" get <<EOF 2>&1 >/dev/null
|
||||
"$HELPER" get <<EOF 2>&1 >/dev/null
|
||||
host=git.mosaicstack.dev
|
||||
username=no-such-agent
|
||||
|
||||
@@ -365,6 +445,193 @@ if [[ "$nospool_err" != *"NOT WRITTEN"* ]]; then
|
||||
fail=1
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 11. P5-RM-006 (+r1 rework) — caller identity from exec-frozen ancestry.
|
||||
# A seat caller is established by lineage, not by the helper's own env;
|
||||
# disagreement anywhere in the lineage is a rewrite and refuses; an
|
||||
# anonymous caller resolves NOTHING on a fleet host (seat or service);
|
||||
# a loose seat-slot mode refuses. Enforcement-removal red control at 11h.
|
||||
# ---------------------------------------------------------------------------
|
||||
mkdir -p "$BRAIN_DIR/fleet/agents/seatG/secrets"
|
||||
echo -n "seatG-slot-token" > "$BRAIN_DIR/fleet/agents/seatG/secrets/gitea-mosaicstack-seatG.token"
|
||||
chmod 600 "$BRAIN_DIR/fleet/agents/seatG/secrets/gitea-mosaicstack-seatG.token"
|
||||
|
||||
# 11a. Cross-seat negative (lineage seatE, env ident=seatG): still refused.
|
||||
assert_refused_lineage "seat cannot override identity to another seat's slot" \
|
||||
seatE cross-seat-identity-refused MOSAIC_GIT_IDENTITY=seatG
|
||||
|
||||
# 11b. Seat asking for a SERVICE identity: cross-seat territory.
|
||||
assert_refused_lineage "seat cannot resolve a service identity either" \
|
||||
seatE cross-seat-identity-refused MOSAIC_GIT_IDENTITY=agentA
|
||||
|
||||
# 11c. Anonymous caller asking for a SEAT slot: refused (T94 jarvis@ class).
|
||||
assert_refused_lineage "anonymous caller cannot resolve a seat slot on a fleet host" \
|
||||
"" anonymous-credential-refused MOSAIC_GIT_IDENTITY=seatG
|
||||
|
||||
# 11d. Anonymous caller asking for a SERVICE identity: ALSO refused
|
||||
# (rev-code-02 F2 — credentialed services are seats; the legacy store is
|
||||
# not anonymously reachable on fleet hosts).
|
||||
assert_refused_lineage "anonymous caller cannot resolve a legacy service credential either" \
|
||||
"" anonymous-credential-refused MOSAIC_GIT_IDENTITY=agentA
|
||||
|
||||
# 11e. Slot permissions: a group-readable slot is refused; mode restored -> serves.
|
||||
chmod 644 "$BRAIN_DIR/fleet/agents/seatG/secrets/gitea-mosaicstack-seatG.token"
|
||||
assert_refused_lineage "loose slot mode is refused" \
|
||||
seatG slot-permission-violation MOSAIC_AGENT_NAME=seatG MOSAIC_GIT_IDENTITY=seatG
|
||||
chmod 600 "$BRAIN_DIR/fleet/agents/seatG/secrets/gitea-mosaicstack-seatG.token"
|
||||
out=$(run_lineage seatG MOSAIC_AGENT_NAME=seatG MOSAIC_GIT_IDENTITY=seatG)
|
||||
assert_eq "mode restored to 600: seatG serves again" "password=seatG-slot-token" "$(echo "$out" | grep '^password=')"
|
||||
|
||||
# 11f. rev-code-02 F1 repro: dual-variable override (caller lineage seatE,
|
||||
# helper env carrying MOSAIC_AGENT_NAME=seatG AND MOSAIC_GIT_IDENTITY=seatG).
|
||||
assert_refused_lineage "F1: dual MOSAIC_AGENT_NAME+MOSAIC_GIT_IDENTITY override refused" \
|
||||
seatE caller-identity-spoof-refused MOSAIC_AGENT_NAME=seatG MOSAIC_GIT_IDENTITY=seatG
|
||||
|
||||
# 11g. Stripped lineage still serves the rightful seat: caller frozen at the
|
||||
# ancestor, helper env clean (self empty), own ident.
|
||||
out=$(run_lineage seatE MOSAIC_GIT_IDENTITY=seatE)
|
||||
assert_eq "lineage consensus with stripped self still serves the owning seat" \
|
||||
"password=seatE-slot-token" "$(echo "$out" | grep '^password=')"
|
||||
|
||||
# 11h. RED CONTROL: delete the ancestry binding between markers from a copy
|
||||
# of the IMPLEMENTATION (run directly with the clean marker — a red control
|
||||
# measures the binding itself, deliberately not through the wrapper);
|
||||
# the F1 dual-override request must then RESOLVE seatG's token — the
|
||||
# exact measured failure — proving the binding is the enforcement.
|
||||
RED_HELPER="$WORK_DIR/red/git-credential-mosaic.impl"
|
||||
mkdir -p "$WORK_DIR/red"
|
||||
sed '/P5-RM-006r1 ancestry binding begin/,/P5-RM-006r1 ancestry binding end/d' "$IMPL" > "$RED_HELPER"
|
||||
chmod +x "$RED_HELPER"
|
||||
if cmp -s "$IMPL" "$RED_HELPER"; then
|
||||
echo "FAIL: red control is vacuous — marker deletion removed nothing" >&2
|
||||
fail=1
|
||||
fi
|
||||
set +e
|
||||
red_out=$(printf 'host=git.mosaicstack.dev\nusername=probe\n\n' | \
|
||||
env -i HOME="$FAKE_HOME" PATH="$PATH" MOSAIC_CREDENTIAL_LINEAGE_FENCE=1 \
|
||||
bash "$WORK_DIR/lineage-root.sh" seatE "$WORK_DIR/lineage-carrier.sh" \
|
||||
"$RED_HELPER" "$SPOOL_DIR" "$BRAIN_DIR" "$REPO_DIR" \
|
||||
_MOSAIC_HELPER_CLEAN=1 MOSAIC_AGENT_NAME=seatG MOSAIC_GIT_IDENTITY=seatG 2>/dev/null)
|
||||
set -e
|
||||
if [[ "$(echo "$red_out" | grep '^password=')" != "password=seatG-slot-token" ]]; then
|
||||
echo "FAIL: red control — with the binding removed, the F1 dual-override should have resolved seatG's token, got: $red_out" >&2
|
||||
fail=1
|
||||
else
|
||||
echo "ok: red control — binding removed -> F1 dual-override resolves the victim token (the binding is the enforcement)"
|
||||
fi
|
||||
|
||||
# 11h2. rev-code-02 R1 F1: PATH-shadowed tr/sed/head/grep must not forge the
|
||||
# ancestry. The dual-override request runs with a hostile PATH whose
|
||||
# utilities claim the victim name for every /proc read; the walker uses
|
||||
# only bash builtins, so the shadows never execute and the refusal holds.
|
||||
HOSTILE_BIN="$WORK_DIR/hostile-bin"
|
||||
mkdir -p "$HOSTILE_BIN"
|
||||
for tool in tr sed head grep cat stat; do
|
||||
printf '#!/usr/bin/env bash\ncat >/dev/null\necho "MOSAIC_AGENT_NAME=seatG"\nexit 0\n' > "$HOSTILE_BIN/$tool"
|
||||
chmod +x "$HOSTILE_BIN/$tool"
|
||||
done
|
||||
assert_refused_lineage "F1-R1: hostile PATH utilities cannot forge ancestry (dual override still refused)" \
|
||||
seatE caller-identity-spoof-refused \
|
||||
MOSAIC_AGENT_NAME=seatG MOSAIC_GIT_IDENTITY=seatG PATH="$HOSTILE_BIN:$PATH"
|
||||
|
||||
# 11i. Service automation integration arm (rev-code-02 R2 bar): a SERVICE
|
||||
# seat bound by lineage resolves its own slot — the brain-git-sync shape.
|
||||
mkdir -p "$BRAIN_DIR/fleet/agents/svc-fixture/secrets"
|
||||
echo -n "svc-fixture-slot-token" > "$BRAIN_DIR/fleet/agents/svc-fixture/secrets/gitea-mosaicstack-svc-fixture.token"
|
||||
chmod 600 "$BRAIN_DIR/fleet/agents/svc-fixture/secrets/gitea-mosaicstack-svc-fixture.token"
|
||||
out=$(run_lineage svc-fixture MOSAIC_AGENT_NAME=svc-fixture MOSAIC_GIT_IDENTITY=svc-fixture)
|
||||
assert_eq "service automation with bound seat lineage resolves its own slot" \
|
||||
"password=svc-fixture-slot-token" "$(echo "$out" | grep '^password=')"
|
||||
|
||||
# 11j. rev-code-02 R3 B1: BASH_ENV shaping. A read() shadow defined through
|
||||
# BASH_ENV must be refused before any resolution — the guard scrubs and
|
||||
# refuses with bash-environment-injection-refused.
|
||||
INJ_SH="$WORK_DIR/inj-read.sh"
|
||||
printf 'read() { builtin read -r _x || return 0; printf "MOSAIC_AGENT_NAME=seatG\\n"; return 0; }\n' > "$INJ_SH"
|
||||
assert_refused_lineage "F1-R3: BASH_ENV read() shadow is dropped at the wrapper boundary (identity gate governs)" \
|
||||
seatE caller-identity-spoof-refused \
|
||||
MOSAIC_AGENT_NAME=seatG MOSAIC_GIT_IDENTITY=seatG BASH_ENV="$INJ_SH"
|
||||
|
||||
# 11k. rev-code-02 R3 B1: exported functions (BASH_FUNC_* import) refused too.
|
||||
assert_refused_lineage "F1-R3: exported BASH_FUNC_* import never crosses the wrapper boundary (identity gate governs)" \
|
||||
seatE caller-identity-spoof-refused \
|
||||
MOSAIC_AGENT_NAME=seatG MOSAIC_GIT_IDENTITY=seatG \
|
||||
'BASH_FUNC_read%%=() { builtin read -r _x || return 0; printf "MOSAIC_AGENT_NAME=seatG\\n"; return 0; }'
|
||||
|
||||
# 11l. rev-code-02 R3 B2: hostile stat cannot launder a loose slot. Own-slot
|
||||
# lineage (legit caller), 0644 slot, PATH-shadowed stat reporting 600 —
|
||||
# mode inspection must come from the trusted PATH and still refuse.
|
||||
chmod 644 "$BRAIN_DIR/fleet/agents/svc-fixture/secrets/gitea-mosaicstack-svc-fixture.token"
|
||||
printf '#!/usr/bin/env bash\necho 600\n' > "$HOSTILE_BIN/stat"
|
||||
assert_refused_lineage "F2-R3: hostile stat cannot make a 0644 slot pass as 600 (own-slot path)" \
|
||||
svc-fixture slot-permission-violation \
|
||||
MOSAIC_AGENT_NAME=svc-fixture MOSAIC_GIT_IDENTITY=svc-fixture PATH="$HOSTILE_BIN:$PATH"
|
||||
chmod 600 "$BRAIN_DIR/fleet/agents/svc-fixture/secrets/gitea-mosaicstack-svc-fixture.token"
|
||||
|
||||
# 11m. rev-code-02 R3 probe 1 through the PRODUCTION ENTRYPOINT: BASH_ENV
|
||||
# defines unset()/exit() no-ops (defeating in-bash scrub/termination).
|
||||
# The python wrapper never passes BASH_ENV across the boundary, so the
|
||||
# implementation cannot be shaped and the dual override still refuses.
|
||||
INJ_P1="$WORK_DIR/inj-probe1.sh"
|
||||
cat > "$INJ_P1" <<'P1'
|
||||
unset() { return 0; }
|
||||
exit() { return 0; }
|
||||
read() { builtin read -r _x || return 0; printf 'MOSAIC_AGENT_NAME=seatG\n'; return 0; }
|
||||
P1
|
||||
assert_refused_lineage "F1-R4 probe1: BASH_ENV unset/exit no-ops cannot shape the helper (wrapper boundary)" \
|
||||
seatE caller-identity-spoof-refused \
|
||||
MOSAIC_AGENT_NAME=seatG MOSAIC_GIT_IDENTITY=seatG BASH_ENV="$INJ_P1"
|
||||
|
||||
# 11n. rev-code-02 R3 probe 2 through the PRODUCTION ENTRYPOINT: declare()
|
||||
# hides imported functions, unsets the marker, printf() forges the
|
||||
# ancestry. Dropped at the wrapper boundary; refusal holds.
|
||||
INJ_P2="$WORK_DIR/inj-probe2.sh"
|
||||
cat > "$INJ_P2" <<'P2'
|
||||
declare() { return 0; }
|
||||
printf() { builtin printf '%s' "MOSAIC_AGENT_NAME=seatG"; return 0; }
|
||||
read() { builtin read -r _x || return 0; printf 'MOSAIC_AGENT_NAME=seatG\n'; return 0; }
|
||||
P2
|
||||
assert_refused_lineage "F1-R4 probe2: declare-hide + printf-forge cannot shape the helper (wrapper boundary)" \
|
||||
seatE caller-identity-spoof-refused \
|
||||
MOSAIC_AGENT_NAME=seatG MOSAIC_GIT_IDENTITY=seatG BASH_ENV="$INJ_P2"
|
||||
|
||||
# 11o. RED CONTROL for the wrapper boundary (enforcement-removal): invoke the
|
||||
# IMPLEMENTATION directly, bypassing the wrapper, with the PROBE-1 shape
|
||||
# (unset/exit no-ops) and a forged clean marker — exactly the falsified
|
||||
# in-bash world the reviewer measured: the refusal prints, exit is
|
||||
# no-oped, execution continues, and the forged ancestry SERVES seatG.
|
||||
# The wrapper boundary is the enforcement; this arm proves it bites.
|
||||
set +e
|
||||
bypass_out=$(cd "$REPO_DIR" && printf 'host=git.mosaicstack.dev\nusername=probe\n\n' | \
|
||||
env -i HOME="$FAKE_HOME" PATH="$PATH" MOSAIC_CREDENTIAL_SPOOL="$SPOOL_DIR" \
|
||||
MOSAIC_BRAIN_HOME="$BRAIN_DIR" _MOSAIC_HELPER_CLEAN=1 BASH_ENV="$INJ_P1" \
|
||||
MOSAIC_AGENT_NAME=seatG MOSAIC_GIT_IDENTITY=seatG \
|
||||
bash "$IMPL" get 2>/dev/null)
|
||||
set -e
|
||||
if [[ "$(echo "$bypass_out" | grep -c '^password=')" -lt 1 ]]; then
|
||||
echo "FAIL: wrapper red control — direct shaped .impl should have served (wrapper is the enforcement), got: $bypass_out" >&2
|
||||
fail=1
|
||||
else
|
||||
echo "ok: red control — wrapper bypassed + probe1 shape serves (the wrapper boundary is the enforcement)"
|
||||
fi
|
||||
|
||||
# 11p. Direct .impl invocation WITHOUT the clean marker: refused by the
|
||||
# implementation's own entrypoint assert.
|
||||
set +e
|
||||
direct_out=$(cd "$REPO_DIR" && printf 'host=git.mosaicstack.dev\nusername=probe\n\n' | \
|
||||
env -i HOME="$FAKE_HOME" PATH="$PATH" MOSAIC_CREDENTIAL_SPOOL="$SPOOL_DIR" \
|
||||
MOSAIC_BRAIN_HOME="$BRAIN_DIR" MOSAIC_GIT_IDENTITY=seatE \
|
||||
bash "$IMPL" get 2>"$WORK_DIR/stderr-direct.tmp")
|
||||
direct_rc=$?
|
||||
set -e
|
||||
if [[ "$direct_rc" -eq 0 || -n "$direct_out" ]]; then
|
||||
echo "FAIL: direct .impl without marker must refuse (got rc=$direct_rc out='$direct_out')" >&2
|
||||
fail=1
|
||||
elif ! grep -q 'direct-entrypoint-refused' "$WORK_DIR/stderr-direct.tmp"; then
|
||||
echo "FAIL: direct .impl refusal lacks direct-entrypoint-refused" >&2
|
||||
fail=1
|
||||
fi
|
||||
|
||||
if [[ "$fail" -eq 0 ]]; then
|
||||
echo "git-credential-mosaic identity resolution regression passed"
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
import { Command } from 'commander';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { registerAgentCommand, runEnroll, runGetEnrollment } from './agent.js';
|
||||
import { enrollAgent, fetchEnrollment } from '../tui/gateway-api.js';
|
||||
|
||||
/**
|
||||
* CLI-parity witness for the agent enrollment family (design
|
||||
* docs/plans/2026-08-29-agent-enrollment-command-design.md §5 item 10;
|
||||
* contract 5 §4.5): `mosaic agent enroll` / `mosaic agent enrollment <id>`
|
||||
* invoke the same gateway commands with the same request/result/error
|
||||
* contracts the web client uses. The gateway side of the same routes is
|
||||
* witnessed in apps/gateway/src/enrollment/enrollment-commands.integration.test.ts.
|
||||
*/
|
||||
|
||||
const gateway = 'https://gateway.example.test';
|
||||
const auth = { gateway, cookie: 'session=test' };
|
||||
|
||||
const agentBody = {
|
||||
id: '3fca4f6a-1111-4222-8333-444455556666',
|
||||
name: 'Nova',
|
||||
provider: 'anthropic',
|
||||
model: 'claude-test',
|
||||
status: 'idle',
|
||||
harness: 'fake-harness',
|
||||
persona: null,
|
||||
ownerId: 'user-1',
|
||||
enrolledAt: '2026-08-29T00:00:00.000Z',
|
||||
createdAt: '2026-08-29T00:00:00.000Z',
|
||||
};
|
||||
|
||||
const okResponse = (correlationId: string) =>
|
||||
new Response(JSON.stringify({ ok: true, correlationId, agent: agentBody }), {
|
||||
status: 201,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
process.exitCode = undefined;
|
||||
});
|
||||
|
||||
describe('agent enrollment CLI registration', (): void => {
|
||||
it('registers enroll and enrollment subcommands under `mosaic agent`', () => {
|
||||
const program = new Command();
|
||||
const cmd = registerAgentCommand(program);
|
||||
const names = cmd.commands.map((c) => c.name());
|
||||
expect(names).toContain('enroll');
|
||||
expect(names).toContain('enrollment');
|
||||
});
|
||||
|
||||
it('the enroll subcommand exposes no argv flag that carries a credential value', () => {
|
||||
const program = new Command();
|
||||
const cmd = registerAgentCommand(program);
|
||||
const enroll = cmd.commands.find((c) => c.name() === 'enroll');
|
||||
expect(enroll).toBeDefined();
|
||||
const flags = (enroll as Command).options.map((o) => o.flags);
|
||||
// --credential selects the MODE only; the intake value arrives via stdin.
|
||||
expect(flags).toContain('--credential <mode>');
|
||||
for (const flag of flags) {
|
||||
expect(flag).not.toMatch(/value|key <secret>|api-key/i);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('agent.enroll CLI parity', (): void => {
|
||||
it('posts the §3.1 request shape for reference mode and surfaces the typed result', async () => {
|
||||
const fetchMock = vi.fn(async () => okResponse('corr-ref'));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
|
||||
await runEnroll(auth, {
|
||||
gateway,
|
||||
harness: 'fake-harness',
|
||||
name: 'Nova',
|
||||
model: 'claude-test',
|
||||
provider: 'anthropic',
|
||||
credential: 'reference',
|
||||
idempotencyKey: '9c1a26be-0000-4000-8000-000000000001',
|
||||
correlationId: 'corr-ref',
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
|
||||
expect(url).toBe(`${gateway}/api/enrollment/agents`);
|
||||
expect(init.method).toBe('POST');
|
||||
expect(JSON.parse(init.body as string)).toEqual({
|
||||
harness: 'fake-harness',
|
||||
name: 'Nova',
|
||||
model: 'claude-test',
|
||||
provider: 'anthropic',
|
||||
credential: { mode: 'reference' },
|
||||
idempotencyKey: '9c1a26be-0000-4000-8000-000000000001',
|
||||
correlationId: 'corr-ref',
|
||||
});
|
||||
expect(log.mock.calls.flat().join('\n')).toContain(agentBody.id);
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
});
|
||||
|
||||
it('intake mode reads the credential from the injected stdin reader, never argv', async () => {
|
||||
const fetchMock = vi.fn(async () => okResponse('corr-intake'));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
|
||||
await runEnroll(
|
||||
auth,
|
||||
{
|
||||
gateway,
|
||||
harness: 'fake-harness',
|
||||
name: 'Nova',
|
||||
model: 'claude-test',
|
||||
provider: 'anthropic',
|
||||
credential: 'intake',
|
||||
idempotencyKey: '9c1a26be-0000-4000-8000-000000000002',
|
||||
},
|
||||
async () => 'stdin-provided-key',
|
||||
);
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
|
||||
const body = JSON.parse(init.body as string) as { credential: Record<string, string> };
|
||||
expect(body.credential).toEqual({
|
||||
mode: 'intake',
|
||||
type: 'api_key',
|
||||
value: 'stdin-provided-key',
|
||||
});
|
||||
});
|
||||
|
||||
it('an empty intake credential refuses locally with no gateway call', async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
await runEnroll(
|
||||
auth,
|
||||
{
|
||||
gateway,
|
||||
harness: 'fake-harness',
|
||||
name: 'Nova',
|
||||
model: 'claude-test',
|
||||
provider: 'anthropic',
|
||||
credential: 'intake',
|
||||
},
|
||||
async () => '',
|
||||
);
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it('preserves the gateway refusal contract (closed error enum + correlation) in the CLI error', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(
|
||||
async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
statusCode: 409,
|
||||
error: 'conflict',
|
||||
message: 'idempotency conflict',
|
||||
correlationId: 'corr-409',
|
||||
}),
|
||||
{ status: 409, headers: { 'Content-Type': 'application/json' } },
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await expect(
|
||||
enrollAgent(gateway, auth.cookie, {
|
||||
harness: 'fake-harness',
|
||||
name: 'Nova',
|
||||
model: 'claude-test',
|
||||
provider: 'anthropic',
|
||||
credential: { mode: 'reference' },
|
||||
idempotencyKey: '9c1a26be-0000-4000-8000-000000000003',
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
'Failed to enroll agent (409): {"statusCode":409,"error":"conflict",' +
|
||||
'"message":"idempotency conflict","correlationId":"corr-409"}',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('agent.enrollment.get CLI parity', (): void => {
|
||||
it('reads one enrollment with the correlation envelope on the query string', async () => {
|
||||
const fetchMock = vi.fn(async () => okResponse('corr-get'));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
|
||||
await runGetEnrollment(auth, agentBody.id, 'corr-get');
|
||||
|
||||
const [url] = fetchMock.mock.calls[0] as unknown as [string];
|
||||
expect(url).toBe(`${gateway}/api/enrollment/agents/${agentBody.id}?correlationId=corr-get`);
|
||||
expect(log.mock.calls.flat().join('\n')).toContain('corr-get');
|
||||
});
|
||||
|
||||
it('preserves the folded not_found contract in the CLI error', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(
|
||||
async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
statusCode: 404,
|
||||
error: 'not_found',
|
||||
message: 'agent not found',
|
||||
correlationId: 'corr-404',
|
||||
}),
|
||||
{ status: 404, headers: { 'Content-Type': 'application/json' } },
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await expect(fetchEnrollment(gateway, auth.cookie, agentBody.id)).rejects.toThrow(
|
||||
'Failed to get enrollment (404): {"statusCode":404,"error":"not_found",' +
|
||||
'"message":"agent not found","correlationId":"corr-404"}',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { Command } from 'commander';
|
||||
import { registerFleetAgentCommands, type FleetCommandDeps } from './fleet.js';
|
||||
import { withAuth } from './with-auth.js';
|
||||
@@ -9,8 +10,10 @@ import {
|
||||
deleteAgentConfig,
|
||||
fetchProjects,
|
||||
fetchProviders,
|
||||
enrollAgent,
|
||||
fetchEnrollment,
|
||||
} from '../tui/gateway-api.js';
|
||||
import type { AgentConfigInfo } from '../tui/gateway-api.js';
|
||||
import type { AgentConfigInfo, EnrolledAgentInfo } from '../tui/gateway-api.js';
|
||||
|
||||
function formatAgent(a: AgentConfigInfo): string {
|
||||
const sys = a.isSystem ? ' [system]' : '';
|
||||
@@ -75,11 +78,140 @@ export function registerAgentCommand(program: Command, fleetDeps: FleetCommandDe
|
||||
},
|
||||
);
|
||||
|
||||
registerEnrollmentCommands(cmd);
|
||||
registerFleetAgentCommands(cmd, fleetDeps);
|
||||
|
||||
return cmd;
|
||||
}
|
||||
|
||||
// ── Agent enrollment (design docs/plans/2026-08-29-agent-enrollment-command-design.md §3;
|
||||
// CLI parity bound by contract 5 §4.5) ──
|
||||
|
||||
export interface EnrollCommandOptions {
|
||||
gateway: string;
|
||||
harness: string;
|
||||
name: string;
|
||||
model: string;
|
||||
provider: string;
|
||||
persona?: string;
|
||||
credential: string;
|
||||
idempotencyKey?: string;
|
||||
correlationId?: string;
|
||||
replayMode?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read an intake credential value from stdin. Never accepted via argv — a
|
||||
* process argument is world-readable in `ps` for the process lifetime.
|
||||
*/
|
||||
export async function readCredentialFromStdin(): Promise<string> {
|
||||
if (process.stdin.isTTY) {
|
||||
console.error('Enter API key, then press Enter and Ctrl-D:');
|
||||
}
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of process.stdin) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
}
|
||||
return Buffer.concat(chunks)
|
||||
.toString('utf8')
|
||||
.replace(/\r?\n$/, '');
|
||||
}
|
||||
|
||||
function showEnrollment(correlationId: string, agent: EnrolledAgentInfo): void {
|
||||
console.log(` ID: ${agent.id}`);
|
||||
console.log(` Name: ${agent.name}`);
|
||||
console.log(` Harness: ${agent.harness ?? '—'}`);
|
||||
console.log(` Provider: ${agent.provider}`);
|
||||
console.log(` Model: ${agent.model}`);
|
||||
console.log(` Status: ${agent.status}`);
|
||||
console.log(` Owner: ${agent.ownerId ?? '—'}`);
|
||||
console.log(` Enrolled: ${agent.enrolledAt ?? '—'}`);
|
||||
console.log(` Correlation: ${correlationId}`);
|
||||
}
|
||||
|
||||
export async function runEnroll(
|
||||
auth: { gateway: string; cookie: string },
|
||||
opts: EnrollCommandOptions,
|
||||
readSecret: () => Promise<string> = readCredentialFromStdin,
|
||||
): Promise<void> {
|
||||
if (opts.credential !== 'reference' && opts.credential !== 'intake') {
|
||||
console.error(`Unknown credential mode "${opts.credential}" (use reference or intake).`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
let credential: { mode: 'reference' } | { mode: 'intake'; type: 'api_key'; value: string };
|
||||
if (opts.credential === 'intake') {
|
||||
const value = await readSecret();
|
||||
if (!value) {
|
||||
console.error('Intake credential requires a non-empty API key on stdin.');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
credential = { mode: 'intake', type: 'api_key', value };
|
||||
} else {
|
||||
credential = { mode: 'reference' };
|
||||
}
|
||||
|
||||
const result = await enrollAgent(auth.gateway, auth.cookie, {
|
||||
harness: opts.harness,
|
||||
name: opts.name,
|
||||
model: opts.model,
|
||||
provider: opts.provider,
|
||||
...(opts.persona !== undefined ? { persona: opts.persona } : {}),
|
||||
credential,
|
||||
idempotencyKey: opts.idempotencyKey ?? randomUUID(),
|
||||
...(opts.correlationId !== undefined ? { correlationId: opts.correlationId } : {}),
|
||||
...(opts.replayMode !== undefined ? { replayMode: opts.replayMode } : {}),
|
||||
});
|
||||
|
||||
console.log(`Agent "${result.agent.name}" enrolled.\n`);
|
||||
showEnrollment(result.correlationId, result.agent);
|
||||
}
|
||||
|
||||
export async function runGetEnrollment(
|
||||
auth: { gateway: string; cookie: string },
|
||||
agentId: string,
|
||||
correlationId?: string,
|
||||
): Promise<void> {
|
||||
const result = await fetchEnrollment(auth.gateway, auth.cookie, agentId, correlationId);
|
||||
showEnrollment(result.correlationId, result.agent);
|
||||
}
|
||||
|
||||
export function registerEnrollmentCommands(cmd: Command): void {
|
||||
cmd
|
||||
.command('enroll')
|
||||
.description('Enroll an agent through the gateway enrollment command (agent.enroll)')
|
||||
.requiredOption('--harness <id>', 'Harness the agent runs on (must be registered)')
|
||||
.requiredOption('--name <name>', 'Agent display name')
|
||||
.requiredOption('--model <model>', 'Model identifier')
|
||||
.requiredOption('--provider <provider>', 'Provider the credential belongs to')
|
||||
.option('--persona <text>', 'Agent persona / system prompt')
|
||||
.option(
|
||||
'--credential <mode>',
|
||||
'Credential mode: "reference" (already stored) or "intake" (API key read from stdin, never argv)',
|
||||
'reference',
|
||||
)
|
||||
.option('--idempotency-key <uuid>', 'Idempotency key (generated when omitted)')
|
||||
.option('--correlation-id <uuid>', 'Correlation id to carry through the audit trail')
|
||||
.option('--replay-mode <mode>', 'Idempotency replay mode (actor-bound)')
|
||||
.action(async (opts: Omit<EnrollCommandOptions, 'gateway'>) => {
|
||||
const parent = cmd.opts<{ gateway: string }>();
|
||||
const auth = await withAuth(parent.gateway);
|
||||
await runEnroll(auth, { ...opts, gateway: parent.gateway });
|
||||
});
|
||||
|
||||
cmd
|
||||
.command('enrollment <agentId>')
|
||||
.description('Read one enrolled agent (agent.enrollment.get; owner or admin)')
|
||||
.option('--correlation-id <uuid>', 'Correlation id to carry through the read')
|
||||
.action(async (agentId: string, opts: { correlationId?: string }) => {
|
||||
const parent = cmd.opts<{ gateway: string }>();
|
||||
const auth = await withAuth(parent.gateway);
|
||||
await runGetEnrollment(auth, agentId, opts.correlationId);
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveAgent(
|
||||
gateway: string,
|
||||
cookie: string,
|
||||
|
||||
@@ -173,6 +173,8 @@ describe('registerFleetCommand', () => {
|
||||
expect(agent!.options.map((option) => option.long)).toContain('--list');
|
||||
expect(agent!.commands.map((command) => command.name()).sort()).toEqual([
|
||||
'comms-block',
|
||||
'enroll',
|
||||
'enrollment',
|
||||
'reset',
|
||||
'roster',
|
||||
'send',
|
||||
|
||||
@@ -148,6 +148,26 @@ describe.skipIf(!hasBash)('bash ↔ TS manifest parity (§6.1)', () => {
|
||||
const manifest = loadManifest(FRAMEWORK_ROOT);
|
||||
expect(bashSubtreeRoots().sort()).toEqual(frameworkSubtreeRoots(manifest).sort());
|
||||
});
|
||||
|
||||
it('fleet/bin ownership is exact and does not prune existing executables (T110 B1)', () => {
|
||||
// fleet/bin carries estate executables this package does not ship. A
|
||||
// subtree glob here would classify them framework-owned and keep-mode
|
||||
// update would prune them. The manifest must own EXACTLY the two shipped
|
||||
// launcher files and nothing else in fleet/bin, on BOTH resolvers.
|
||||
const manifest = loadManifest(FRAMEWORK_ROOT);
|
||||
const expected: Array<[string, string]> = [
|
||||
['fleet/bin/mosaic', 'framework'],
|
||||
['fleet/bin/test-mosaic-launcher.sh', 'framework'],
|
||||
['fleet/bin/seat-up.sh', 'operator'], // shipped-by-estate, unshipped here
|
||||
['fleet/bin/launch-seat.sh', 'operator'],
|
||||
['fleet/bin', 'operator'], // the directory itself is unlisted
|
||||
];
|
||||
for (const [path, want] of expected) {
|
||||
const ts = resolveOwnership(manifest, path);
|
||||
expect(ts).toBe(want);
|
||||
expect(bashResolve(path)).toBe(want);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -562,6 +562,74 @@ export async function fetchInteractionHealth(gatewayUrl: string): Promise<unknow
|
||||
return handleResponse<unknown>(res, 'Failed to get interaction readiness');
|
||||
}
|
||||
|
||||
// ── Agent Enrollment types (design docs/plans/2026-08-29-agent-enrollment-command-design.md §3) ──
|
||||
|
||||
export interface EnrolledAgentInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
model: string;
|
||||
status: string;
|
||||
harness: string | null;
|
||||
persona: string | null;
|
||||
ownerId: string | null;
|
||||
enrolledAt: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface EnrollAgentRequest {
|
||||
harness: string;
|
||||
name: string;
|
||||
persona?: string;
|
||||
model: string;
|
||||
provider: string;
|
||||
credential: { mode: 'reference' } | { mode: 'intake'; type: 'api_key'; value: string };
|
||||
idempotencyKey: string;
|
||||
correlationId?: string;
|
||||
replayMode?: string;
|
||||
}
|
||||
|
||||
export interface EnrollmentOutcome {
|
||||
ok: true;
|
||||
correlationId: string;
|
||||
agent: EnrolledAgentInfo;
|
||||
}
|
||||
|
||||
// ── Agent Enrollment endpoints ──
|
||||
|
||||
/**
|
||||
* agent.enroll. The gateway's typed refusal body (closed error enum +
|
||||
* correlationId) is preserved verbatim in the thrown error, so the CLI
|
||||
* surfaces the same contract the web client receives.
|
||||
*/
|
||||
export async function enrollAgent(
|
||||
gatewayUrl: string,
|
||||
sessionCookie: string,
|
||||
data: EnrollAgentRequest,
|
||||
): Promise<EnrollmentOutcome> {
|
||||
const res = await fetch(`${gatewayUrl}/api/enrollment/agents`, {
|
||||
method: 'POST',
|
||||
headers: jsonHeaders(sessionCookie, gatewayUrl),
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return handleResponse<EnrollmentOutcome>(res, 'Failed to enroll agent');
|
||||
}
|
||||
|
||||
/** agent.enrollment.get: owner-or-admin read of one enrolled agent. */
|
||||
export async function fetchEnrollment(
|
||||
gatewayUrl: string,
|
||||
sessionCookie: string,
|
||||
agentId: string,
|
||||
correlationId?: string,
|
||||
): Promise<EnrollmentOutcome> {
|
||||
const params = correlationId ? `?${new URLSearchParams({ correlationId }).toString()}` : '';
|
||||
const res = await fetch(
|
||||
`${gatewayUrl}/api/enrollment/agents/${encodeURIComponent(agentId)}${params}`,
|
||||
{ headers: headers(sessionCookie, gatewayUrl) },
|
||||
);
|
||||
return handleResponse<EnrollmentOutcome>(res, 'Failed to get enrollment');
|
||||
}
|
||||
|
||||
// ── Conversation Message types ──
|
||||
|
||||
export interface ConversationMessage {
|
||||
|
||||
Executable
+144
@@ -0,0 +1,144 @@
|
||||
#!/usr/bin/env bash
|
||||
# Hermetic structural check for the explicit dogfood Compose overlay.
|
||||
set -euo pipefail
|
||||
|
||||
repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
||||
tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
mkdir -p "$tmp/worktree" "$tmp/seat/secrets"
|
||||
|
||||
base_config_json=$(
|
||||
cd "$repo_root"
|
||||
BETTER_AUTH_SECRET=test-only-not-a-credential \
|
||||
docker compose --profile stack config --format json
|
||||
)
|
||||
|
||||
BASE_CONFIG_JSON="$base_config_json" python3 <<'PY'
|
||||
import json
|
||||
import os
|
||||
|
||||
config = json.loads(os.environ["BASE_CONFIG_JSON"])
|
||||
gateway = config["services"]["gateway"]
|
||||
env = gateway["environment"]
|
||||
for key in (
|
||||
"MOSAIC_AGENT_NAME",
|
||||
"MOSAIC_GIT_IDENTITY",
|
||||
"MOSAIC_BRAIN_HOME",
|
||||
"AGENT_FILE_SANDBOX_DIR",
|
||||
"AGENT_USER_TOOLS",
|
||||
):
|
||||
assert key not in env, f"base compose unexpectedly sets dogfood variable {key}"
|
||||
|
||||
targets = {mount["target"] for mount in gateway["volumes"]}
|
||||
assert "/workspace/stack" not in targets
|
||||
assert not any(target.startswith("/opt/mosaic/brain/") for target in targets)
|
||||
PY
|
||||
|
||||
config_json=$(
|
||||
cd "$repo_root"
|
||||
BETTER_AUTH_SECRET=test-only-not-a-credential \
|
||||
MOSAIC_DOGFOOD_WORKTREE="$tmp/worktree" \
|
||||
MOSAIC_DOGFOOD_SEAT_HOME="$tmp/seat" \
|
||||
docker compose \
|
||||
-f docker-compose.yml \
|
||||
-f docker-compose.dogfood.yml \
|
||||
--profile stack \
|
||||
config --format json
|
||||
)
|
||||
|
||||
CONFIG_JSON="$config_json" EXPECT_WORKTREE="$tmp/worktree" EXPECT_SEAT="$tmp/seat" python3 <<'PY'
|
||||
import json
|
||||
import os
|
||||
|
||||
config = json.loads(os.environ["CONFIG_JSON"])
|
||||
gateway = config["services"]["gateway"]
|
||||
env = gateway["environment"]
|
||||
|
||||
expected_env = {
|
||||
"MOSAIC_AGENT_NAME": "stack-dogfood",
|
||||
"MOSAIC_GIT_IDENTITY": "stack-dogfood",
|
||||
"MOSAIC_BRAIN_HOME": "/opt/mosaic/brain",
|
||||
"AGENT_FILE_SANDBOX_DIR": "/workspace/stack",
|
||||
}
|
||||
for key, value in expected_env.items():
|
||||
assert env.get(key) == value, f"{key}: expected {value!r}, got {env.get(key)!r}"
|
||||
|
||||
allowed = set(env["AGENT_USER_TOOLS"].split(","))
|
||||
assert allowed == {
|
||||
"fs_read_file",
|
||||
"fs_write_file",
|
||||
"fs_list_directory",
|
||||
"fs_edit_file",
|
||||
"git_status",
|
||||
"git_log",
|
||||
"git_diff",
|
||||
"shell_exec",
|
||||
}, f"unexpected dogfood tool set: {sorted(allowed)}"
|
||||
|
||||
mounts = {mount["target"]: mount for mount in gateway["volumes"]}
|
||||
worktree = mounts["/workspace/stack"]
|
||||
assert worktree["type"] == "bind"
|
||||
assert worktree["source"] == os.environ["EXPECT_WORKTREE"]
|
||||
assert not worktree.get("read_only", False), "dogfood worktree must be writable"
|
||||
|
||||
seat = mounts["/opt/mosaic/brain/fleet/agents/stack-dogfood"]
|
||||
assert seat["type"] == "bind"
|
||||
assert seat["source"] == os.environ["EXPECT_SEAT"]
|
||||
assert seat.get("read_only") is True, "seat credential slot must be read-only"
|
||||
|
||||
other_seat_mounts = [
|
||||
target
|
||||
for target in mounts
|
||||
if target.startswith("/opt/mosaic/brain/fleet/agents/")
|
||||
and target != "/opt/mosaic/brain/fleet/agents/stack-dogfood"
|
||||
]
|
||||
assert other_seat_mounts == [], f"other seat mounts leaked: {other_seat_mounts}"
|
||||
PY
|
||||
|
||||
# Each required path must fail closed rather than falling back to the current checkout.
|
||||
expect_missing_path() {
|
||||
local missing=$1 output rc
|
||||
set +e
|
||||
case "$missing" in
|
||||
MOSAIC_DOGFOOD_WORKTREE)
|
||||
output=$(
|
||||
cd "$repo_root"
|
||||
env -u MOSAIC_DOGFOOD_WORKTREE \
|
||||
BETTER_AUTH_SECRET=test-only-not-a-credential \
|
||||
MOSAIC_DOGFOOD_SEAT_HOME="$tmp/seat" \
|
||||
docker compose -f docker-compose.yml -f docker-compose.dogfood.yml \
|
||||
--profile stack config 2>&1
|
||||
)
|
||||
rc=$?
|
||||
;;
|
||||
MOSAIC_DOGFOOD_SEAT_HOME)
|
||||
output=$(
|
||||
cd "$repo_root"
|
||||
env -u MOSAIC_DOGFOOD_SEAT_HOME \
|
||||
BETTER_AUTH_SECRET=test-only-not-a-credential \
|
||||
MOSAIC_DOGFOOD_WORKTREE="$tmp/worktree" \
|
||||
docker compose -f docker-compose.yml -f docker-compose.dogfood.yml \
|
||||
--profile stack config 2>&1
|
||||
)
|
||||
rc=$?
|
||||
;;
|
||||
*)
|
||||
echo "FAIL: test requested unknown path variable $missing" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
set -e
|
||||
if [[ $rc -eq 0 ]]; then
|
||||
echo "FAIL: dogfood compose accepted missing $missing" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$output" != *"$missing"* ]]; then
|
||||
echo "FAIL: missing-path failure did not name $missing" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
expect_missing_path MOSAIC_DOGFOOD_WORKTREE
|
||||
expect_missing_path MOSAIC_DOGFOOD_SEAT_HOME
|
||||
|
||||
printf 'dogfood compose verification passed\n'
|
||||
Reference in New Issue
Block a user