Compare commits
1 Commits
test/758-r
...
feat/769-k
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12b58c3eaa |
13
CLAUDE.md
13
CLAUDE.md
@@ -26,14 +26,13 @@ pnpm test # Vitest (all packages)
|
||||
pnpm build # Build all packages
|
||||
|
||||
# Database
|
||||
pnpm --filter @mosaicstack/db db:generate # Offline migration artifact generation only
|
||||
# PostgreSQL execution is held until KBN-101-00/-03/-05 land. Do not invoke a runner,
|
||||
# init SQL, or Compose PostgreSQL service from this checkout.
|
||||
pnpm --filter @mosaicstack/db db:push # Push schema to PG (dev)
|
||||
pnpm --filter @mosaicstack/db db:generate # Generate migrations
|
||||
pnpm --filter @mosaicstack/db db:migrate # Run migrations
|
||||
|
||||
# Dev: local PGlite data-layer work needs no PostgreSQL. Optional local queue service only:
|
||||
docker compose up -d valkey
|
||||
# Do not start Gateway/Web or root pnpm dev as a local PGlite route: the current unguarded dotenv
|
||||
# loader can inherit a daemon PostgreSQL DSN. KBN-101-02 must make that state fail closed first.
|
||||
# Dev
|
||||
docker compose up -d # Start PG, Valkey, OTEL, Jaeger
|
||||
pnpm --filter @mosaicstack/gateway exec tsx src/main.ts # Start gateway
|
||||
```
|
||||
|
||||
## Conventions
|
||||
|
||||
44
README.md
44
README.md
@@ -157,12 +157,7 @@ mosaic storage status
|
||||
mosaic storage tier
|
||||
mosaic storage export
|
||||
mosaic storage import
|
||||
# Schema migration is unavailable in this release. The current storage wrapper shells
|
||||
# directly to `pnpm --filter @mosaicstack/db db:migrate`; it is legacy N-1,
|
||||
# uncertified, and MUST NOT be invoked pending KBN-101-02/-03/-06/-08 activation.
|
||||
# Future schema migration is non-operative: external bootstrap → TLS/roles → runner
|
||||
# --run → runner --verify → readiness. Tier copy uses only the separately held secure
|
||||
# migrate-tier route.
|
||||
mosaic storage migrate
|
||||
```
|
||||
|
||||
### Telemetry
|
||||
@@ -197,32 +192,29 @@ Consent state is persisted in config. Remote upload is a no-op until you run `mo
|
||||
git clone git@git.mosaicstack.dev:mosaicstack/stack.git
|
||||
cd stack
|
||||
|
||||
# Install dependencies. The local tier uses in-process PGlite; leave DATABASE_URL unset.
|
||||
# Start infrastructure (Postgres, Valkey, Jaeger)
|
||||
docker compose up -d
|
||||
|
||||
# Install dependencies
|
||||
pnpm install
|
||||
|
||||
# Optional local queue service only. This does not start PostgreSQL.
|
||||
docker compose up -d valkey
|
||||
# Run migrations
|
||||
pnpm --filter @mosaicstack/db run db:migrate
|
||||
|
||||
# The current Gateway/Web local process is held; see docs/guides/dev-guide.md.
|
||||
# Do not start it until KBN-101-02 makes inherited dotenv/DSN state fail closed.
|
||||
# Start all services in dev mode
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
### Held future procedure
|
||||
### Infrastructure
|
||||
|
||||
The checked-in Compose PostgreSQL service mounts legacy initialization SQL and is **not** a
|
||||
current PostgreSQL, standalone, or federated developer route. Do not start it with Compose,
|
||||
invoke initialization SQL, or treat the planned migrator as currently executable.
|
||||
Docker Compose provides:
|
||||
|
||||
**Held future activation procedure — non-operative and no current command authority until KBN-101-00, KBN-101-03, and KBN-101-05
|
||||
land:** external bootstrap → TLS/roles → `mosaic-db-migrator --run` →
|
||||
`mosaic-db-migrator --verify` → Gateway/Compose readiness. The future deployment artifacts—not
|
||||
this README—will provide the reviewed commands and secret-consumer interface.
|
||||
|
||||
For local data-layer work, PGlite needs no PostgreSQL service. The optional Compose command above
|
||||
starts only Valkey; OTEL Collector and Jaeger may likewise be started individually if needed,
|
||||
without starting PostgreSQL. A Gateway/Web local process is not currently a safe PGlite route:
|
||||
its unguarded dotenv loader may inherit a daemon PostgreSQL DSN. Do not use root `pnpm dev` or a
|
||||
Gateway start command until KBN-101-02 makes that state fail closed.
|
||||
| Service | Port | Purpose |
|
||||
| --------------------- | --------- | ---------------------- |
|
||||
| PostgreSQL (pgvector) | 5433 | Primary database |
|
||||
| Valkey | 6380 | Task queue + caching |
|
||||
| Jaeger | 16686 | Distributed tracing UI |
|
||||
| OTEL Collector | 4317/4318 | Telemetry ingestion |
|
||||
|
||||
### Quality Gates
|
||||
|
||||
@@ -239,7 +231,7 @@ pnpm format # Prettier auto-fix
|
||||
Woodpecker CI runs on every push:
|
||||
|
||||
- `pnpm install --frozen-lockfile`
|
||||
- **Legacy N-1 CI status only — active, uncertified, and non-authorizing as an operator route:** the checked-in job currently invokes `pnpm --filter @mosaicstack/db run db:migrate` with `DATABASE_URL` against an isolated disposable PostgreSQL CI database. It performs direct DDL in that CI database, is not approved ordinary behavior or an operator route, and remains a known exception pending KBN-101-06 removal/replacement by the certified runner-backed CI path.
|
||||
- Database migration against a fresh Postgres
|
||||
- `pnpm test` (Turbo-orchestrated across all packages)
|
||||
|
||||
npm packages are published to the Gitea package registry on main merges.
|
||||
|
||||
@@ -21,12 +21,6 @@ import { LogModule } from '../log/log.module.js';
|
||||
import { CommandsModule } from '../commands/commands.module.js';
|
||||
import { CommandRuntimeApprovalVerifier } from '../commands/runtime-approval-verifier.js';
|
||||
import { GatewayHermesRuntimeTransport } from './hermes-runtime.transport.js';
|
||||
import { ConnectorLeaseRepository } from './connector-lease.repository.js';
|
||||
import {
|
||||
CONNECTOR_LEASE_POLICY,
|
||||
ConnectorLeaseService,
|
||||
DenyConnectorLeasePolicy,
|
||||
} from './connector-lease.service.js';
|
||||
import {
|
||||
AGENT_RUNTIME_PROVIDER_REGISTRY,
|
||||
RUNTIME_APPROVAL_VERIFIER,
|
||||
@@ -52,13 +46,6 @@ export function createGatewayRuntimeProviderRegistry(): AgentRuntimeProviderRegi
|
||||
SkillLoaderService,
|
||||
DurableSessionRepository,
|
||||
DurableSessionService,
|
||||
ConnectorLeaseRepository,
|
||||
DenyConnectorLeasePolicy,
|
||||
{
|
||||
provide: CONNECTOR_LEASE_POLICY,
|
||||
useExisting: DenyConnectorLeasePolicy,
|
||||
},
|
||||
ConnectorLeaseService,
|
||||
{
|
||||
provide: AGENT_RUNTIME_PROVIDER_REGISTRY,
|
||||
useFactory: createGatewayRuntimeProviderRegistry,
|
||||
@@ -91,7 +78,6 @@ export function createGatewayRuntimeProviderRegistry(): AgentRuntimeProviderRegi
|
||||
SkillLoaderService,
|
||||
DurableSessionService,
|
||||
RuntimeProviderService,
|
||||
ConnectorLeaseService,
|
||||
AGENT_RUNTIME_PROVIDER_REGISTRY,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -1,341 +0,0 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
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 {
|
||||
connectorLeaseAuditLog,
|
||||
createPgliteDb,
|
||||
eq,
|
||||
runPgliteMigrations,
|
||||
type DbHandle,
|
||||
} from '@mosaicstack/db';
|
||||
import type { ConnectorExecutionContext, FencedConnectorAdapter } from '@mosaicstack/types';
|
||||
import { DB } from '../database/database.module.js';
|
||||
import { ConnectorLeaseRepository } from './connector-lease.repository.js';
|
||||
import {
|
||||
CONNECTOR_LEASE_POLICY,
|
||||
ConnectorLeaseService,
|
||||
type ConnectorLeasePolicy,
|
||||
type ConnectorLeasePolicySubject,
|
||||
} from './connector-lease.service.js';
|
||||
|
||||
const authorize = vi.fn().mockResolvedValue(true);
|
||||
const policy: ConnectorLeasePolicy = { authorize };
|
||||
const context = {
|
||||
actorScope: { userId: 'operator-a', tenantId: 'tenant-a' },
|
||||
correlationId: 'correlation-acquire',
|
||||
};
|
||||
|
||||
describe('gateway connector lease fencing integration', (): void => {
|
||||
let dataDir: string;
|
||||
let handle: DbHandle;
|
||||
let moduleRef: TestingModule;
|
||||
let service: ConnectorLeaseService;
|
||||
let repository: ConnectorLeaseRepository;
|
||||
|
||||
beforeAll(async (): Promise<void> => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-07-14T17:00:00.000Z'));
|
||||
dataDir = await mkdtemp(join(tmpdir(), 'mosaic-gateway-connector-lease-'));
|
||||
handle = createPgliteDb(dataDir);
|
||||
await runPgliteMigrations(handle);
|
||||
moduleRef = await Test.createTestingModule({
|
||||
providers: [
|
||||
ConnectorLeaseRepository,
|
||||
ConnectorLeaseService,
|
||||
{ provide: DB, useValue: handle.db },
|
||||
{ provide: CONNECTOR_LEASE_POLICY, useValue: policy },
|
||||
],
|
||||
}).compile();
|
||||
service = moduleRef.get(ConnectorLeaseService);
|
||||
repository = moduleRef.get(ConnectorLeaseRepository);
|
||||
});
|
||||
|
||||
afterAll(async (): Promise<void> => {
|
||||
vi.useRealTimers();
|
||||
await moduleRef.close();
|
||||
await handle.close();
|
||||
await rm(dataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('derives tenant authority at the gateway and validates a grant before side effects', async (): Promise<void> => {
|
||||
const lease = await service.acquire(
|
||||
{
|
||||
logicalAgentId: 'Mos',
|
||||
bindingId: 'operator-chat',
|
||||
connectorId: 'pi-worker-a',
|
||||
scopes: ['runtime.send'],
|
||||
ttlMs: 60_000,
|
||||
},
|
||||
context,
|
||||
);
|
||||
const grant = await service.issueGrant(
|
||||
{ lease, scopes: ['runtime.send'], ttlMs: 30_000 },
|
||||
{ ...context, correlationId: 'correlation-grant' },
|
||||
);
|
||||
const execute = vi.fn(async (_message: string, leaseContext: ConnectorExecutionContext) => {
|
||||
return leaseContext.leaseEpoch;
|
||||
});
|
||||
const adapter: FencedConnectorAdapter<string, string> = { execute };
|
||||
|
||||
await expect(service.executeGrant(grant, 'runtime.send', 'hello', adapter)).resolves.toBe('1');
|
||||
expect(execute).toHaveBeenCalledOnce();
|
||||
expect(authorize).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: 'grant.issue',
|
||||
requestedScopes: ['runtime.send'],
|
||||
requestedTtlMs: 30_000,
|
||||
}),
|
||||
);
|
||||
expect(execute.mock.calls[0]?.[1]).toMatchObject({
|
||||
identity: { tenantId: 'tenant-a', logicalAgentId: 'mos' },
|
||||
bindingId: 'operator-chat',
|
||||
connectorId: 'pi-worker-a',
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes lease-derived policy subjects before authorization', async (): Promise<void> => {
|
||||
const lease = await service.acquire(
|
||||
{
|
||||
logicalAgentId: 'mos',
|
||||
bindingId: 'operator-chat-policy',
|
||||
connectorId: 'pi-worker-a',
|
||||
scopes: ['runtime.send'],
|
||||
ttlMs: 60_000,
|
||||
},
|
||||
{ ...context, correlationId: 'correlation-policy-setup' },
|
||||
);
|
||||
const aliasedLease = {
|
||||
...lease,
|
||||
identity: { ...lease.identity, logicalAgentId: ' MOS ' },
|
||||
bindingId: ' Operator-Chat-Policy ',
|
||||
connectorId: ' PI-Worker-A ',
|
||||
scopes: [' Runtime.Send '],
|
||||
leaseEpoch: `00${lease.leaseEpoch}`,
|
||||
};
|
||||
|
||||
await service.heartbeat(aliasedLease, 30_000, {
|
||||
...context,
|
||||
correlationId: 'correlation-policy-heartbeat',
|
||||
});
|
||||
expect(authorize).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
action: 'lease.heartbeat',
|
||||
logicalAgentId: 'mos',
|
||||
bindingId: 'operator-chat-policy',
|
||||
connectorId: 'pi-worker-a',
|
||||
requestedScopes: ['runtime.send'],
|
||||
}),
|
||||
);
|
||||
|
||||
await service.issueGrant(
|
||||
{ lease: aliasedLease, scopes: [' Runtime.Send '], ttlMs: 1_000 },
|
||||
{ ...context, correlationId: 'correlation-policy-grant' },
|
||||
);
|
||||
expect(authorize).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
action: 'grant.issue',
|
||||
logicalAgentId: 'mos',
|
||||
bindingId: 'operator-chat-policy',
|
||||
connectorId: 'pi-worker-a',
|
||||
requestedScopes: ['runtime.send'],
|
||||
}),
|
||||
);
|
||||
|
||||
await service.release(aliasedLease, {
|
||||
...context,
|
||||
correlationId: 'correlation-policy-release',
|
||||
});
|
||||
expect(authorize).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
action: 'lease.release',
|
||||
logicalAgentId: 'mos',
|
||||
bindingId: 'operator-chat-policy',
|
||||
connectorId: 'pi-worker-a',
|
||||
requestedScopes: ['runtime.send'],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('denies stale, forged, expired, cross-tenant, and cross-binding grants before effects', async (): Promise<void> => {
|
||||
const bindingId = 'operator-chat-denials';
|
||||
const current = await service.acquire(
|
||||
{
|
||||
logicalAgentId: 'mos',
|
||||
bindingId,
|
||||
connectorId: 'pi-worker-a',
|
||||
scopes: ['runtime.send'],
|
||||
ttlMs: 60_000,
|
||||
},
|
||||
{ ...context, correlationId: 'correlation-denial-setup' },
|
||||
);
|
||||
const stale = await service.issueGrant(
|
||||
{ lease: current, scopes: ['runtime.send'], ttlMs: 30_000 },
|
||||
{ ...context, correlationId: 'correlation-stale' },
|
||||
);
|
||||
await service.takeover(
|
||||
{
|
||||
logicalAgentId: 'mos',
|
||||
bindingId,
|
||||
connectorId: 'pi-worker-b',
|
||||
scopes: ['runtime.send'],
|
||||
ttlMs: 60_000,
|
||||
expectedEpoch: current.leaseEpoch,
|
||||
},
|
||||
{ ...context, correlationId: 'correlation-takeover' },
|
||||
);
|
||||
const adapter = { execute: vi.fn().mockResolvedValue(undefined) };
|
||||
|
||||
await expect(service.executeGrant(stale, 'runtime.send', undefined, adapter)).rejects.toThrow();
|
||||
|
||||
const active = await service.current('mos', bindingId, context);
|
||||
if (!active) throw new Error('active lease fixture is unavailable');
|
||||
const grant = await service.issueGrant(
|
||||
{ lease: active, scopes: ['runtime.send'], ttlMs: 1_000 },
|
||||
{ ...context, correlationId: 'correlation-active' },
|
||||
);
|
||||
await expect(
|
||||
service.executeGrant({ ...grant }, 'runtime.send', undefined, adapter),
|
||||
).rejects.toThrow();
|
||||
await expect(
|
||||
service.executeGrant(
|
||||
{ ...grant, bindingId: 'other-binding' },
|
||||
'runtime.send',
|
||||
undefined,
|
||||
adapter,
|
||||
),
|
||||
).rejects.toThrow();
|
||||
await expect(
|
||||
service.issueGrant(
|
||||
{ lease: active, scopes: ['runtime.send'], ttlMs: 30_000 },
|
||||
{
|
||||
actorScope: { userId: 'operator-b', tenantId: 'tenant-b' },
|
||||
correlationId: 'correlation-cross-tenant',
|
||||
},
|
||||
),
|
||||
).rejects.toThrow();
|
||||
const crossTenantAudit = await handle.db
|
||||
.select()
|
||||
.from(connectorLeaseAuditLog)
|
||||
.where(eq(connectorLeaseAuditLog.correlationId, 'correlation-cross-tenant'));
|
||||
expect(crossTenantAudit).toHaveLength(1);
|
||||
expect(crossTenantAudit[0]).toMatchObject({
|
||||
tenantId: 'tenant-b',
|
||||
logicalAgentId: 'untrusted',
|
||||
bindingId: 'untrusted',
|
||||
connectorId: 'untrusted',
|
||||
reason: 'policy_denied',
|
||||
});
|
||||
|
||||
vi.setSystemTime(new Date('2026-07-14T17:00:02.000Z'));
|
||||
await expect(service.executeGrant(grant, 'runtime.send', undefined, adapter)).rejects.toThrow();
|
||||
expect(adapter.execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects submitted lifecycle scopes that differ from durable authority before policy or mutation', async (): Promise<void> => {
|
||||
authorize.mockResolvedValue(true);
|
||||
const heartbeatLease = await service.acquire(
|
||||
{
|
||||
logicalAgentId: 'mos',
|
||||
bindingId: 'operator-chat-heartbeat-scope',
|
||||
connectorId: 'pi-worker-a',
|
||||
scopes: ['runtime.send'],
|
||||
ttlMs: 60_000,
|
||||
},
|
||||
{ ...context, correlationId: 'correlation-heartbeat-scope-setup' },
|
||||
);
|
||||
const releaseLease = await service.acquire(
|
||||
{
|
||||
logicalAgentId: 'mos',
|
||||
bindingId: 'operator-chat-release-scope',
|
||||
connectorId: 'pi-worker-a',
|
||||
scopes: ['runtime.send'],
|
||||
ttlMs: 60_000,
|
||||
},
|
||||
{ ...context, correlationId: 'correlation-release-scope-setup' },
|
||||
);
|
||||
const forgedHeartbeat = { ...heartbeatLease, scopes: ['tool.execute'] };
|
||||
const forgedRelease = { ...releaseLease, scopes: ['tool.execute'] };
|
||||
|
||||
authorize.mockImplementation(async (subject: ConnectorLeasePolicySubject) => {
|
||||
return subject.requestedScopes.length === 1 && subject.requestedScopes[0] === 'tool.execute';
|
||||
});
|
||||
authorize.mockClear();
|
||||
|
||||
await expect(
|
||||
service.heartbeat(forgedHeartbeat, 30_000, {
|
||||
...context,
|
||||
correlationId: 'correlation-heartbeat-scope-forgery',
|
||||
}),
|
||||
).rejects.toThrow('Connector authority policy denied');
|
||||
await expect(
|
||||
service.release(forgedRelease, {
|
||||
...context,
|
||||
correlationId: 'correlation-release-scope-forgery',
|
||||
}),
|
||||
).rejects.toThrow('Connector authority policy denied');
|
||||
expect(authorize).not.toHaveBeenCalled();
|
||||
|
||||
const currentHeartbeat = await repository.findCurrent({
|
||||
identity: heartbeatLease.identity,
|
||||
bindingId: heartbeatLease.bindingId,
|
||||
});
|
||||
const currentRelease = await repository.findCurrent({
|
||||
identity: releaseLease.identity,
|
||||
bindingId: releaseLease.bindingId,
|
||||
});
|
||||
expect(currentHeartbeat).toMatchObject({
|
||||
leaseId: heartbeatLease.leaseId,
|
||||
scopes: ['runtime.send'],
|
||||
heartbeatAt: heartbeatLease.heartbeatAt,
|
||||
expiresAt: heartbeatLease.expiresAt,
|
||||
});
|
||||
expect(currentRelease).toMatchObject({
|
||||
leaseId: releaseLease.leaseId,
|
||||
scopes: ['runtime.send'],
|
||||
});
|
||||
expect(currentRelease?.releasedAt).toBeUndefined();
|
||||
|
||||
const forgedAudits = await handle.db
|
||||
.select()
|
||||
.from(connectorLeaseAuditLog)
|
||||
.where(eq(connectorLeaseAuditLog.correlationId, 'correlation-heartbeat-scope-forgery'));
|
||||
expect(forgedAudits).toHaveLength(1);
|
||||
expect(forgedAudits[0]).toMatchObject({
|
||||
bindingId: heartbeatLease.bindingId,
|
||||
connectorId: heartbeatLease.connectorId,
|
||||
event: 'reject',
|
||||
outcome: 'denied',
|
||||
reason: 'policy_denied',
|
||||
});
|
||||
const forgedReleaseAudits = await handle.db
|
||||
.select()
|
||||
.from(connectorLeaseAuditLog)
|
||||
.where(eq(connectorLeaseAuditLog.correlationId, 'correlation-release-scope-forgery'));
|
||||
expect(forgedReleaseAudits).toHaveLength(1);
|
||||
expect(forgedReleaseAudits[0]).toMatchObject({
|
||||
bindingId: releaseLease.bindingId,
|
||||
connectorId: releaseLease.connectorId,
|
||||
event: 'reject',
|
||||
outcome: 'denied',
|
||||
reason: 'policy_denied',
|
||||
});
|
||||
|
||||
authorize.mockImplementation(async (subject: ConnectorLeasePolicySubject) => {
|
||||
return subject.requestedScopes.length === 1 && subject.requestedScopes[0] === 'runtime.send';
|
||||
});
|
||||
await expect(
|
||||
service.heartbeat(heartbeatLease, 30_000, {
|
||||
...context,
|
||||
correlationId: 'correlation-heartbeat-scope-canonical',
|
||||
}),
|
||||
).resolves.toMatchObject({ scopes: ['runtime.send'] });
|
||||
await expect(
|
||||
service.release(releaseLease, {
|
||||
...context,
|
||||
correlationId: 'correlation-release-scope-canonical',
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,76 +0,0 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
connectorLeaseAuditLog,
|
||||
createDb,
|
||||
eq,
|
||||
logicalAgentConnectorLeases,
|
||||
type DbHandle,
|
||||
} from '@mosaicstack/db';
|
||||
import { ConnectorLeaseCoordinator } from '@mosaicstack/agent';
|
||||
import { ConnectorLeaseRepository } from './connector-lease.repository.js';
|
||||
|
||||
const hasPostgres = Boolean(process.env['DATABASE_URL']);
|
||||
const tenantId = `lease-test-${randomUUID()}`;
|
||||
const identity = { tenantId, logicalAgentId: 'mos' } as const;
|
||||
|
||||
describe.skipIf(!hasPostgres)('ConnectorLeaseRepository real PostgreSQL integration', (): void => {
|
||||
let handle: DbHandle;
|
||||
|
||||
beforeAll((): void => {
|
||||
handle = createDb(process.env['DATABASE_URL']);
|
||||
});
|
||||
|
||||
afterAll(async (): Promise<void> => {
|
||||
if (!handle) return;
|
||||
await handle.db
|
||||
.delete(connectorLeaseAuditLog)
|
||||
.where(eq(connectorLeaseAuditLog.tenantId, tenantId));
|
||||
await handle.db
|
||||
.delete(logicalAgentConnectorLeases)
|
||||
.where(eq(logicalAgentConnectorLeases.tenantId, tenantId));
|
||||
await handle.close();
|
||||
});
|
||||
|
||||
it('preserves the exclusive CAS fence across a real pool close/reopen', async (): Promise<void> => {
|
||||
const command = {
|
||||
identity,
|
||||
bindingId: 'operator-chat',
|
||||
scopes: ['runtime.send'],
|
||||
ttlMs: 60_000,
|
||||
} as const;
|
||||
const firstCoordinator = new ConnectorLeaseCoordinator(new ConnectorLeaseRepository(handle.db));
|
||||
const contenders = await Promise.allSettled([
|
||||
firstCoordinator.acquire({
|
||||
...command,
|
||||
connectorId: 'connector-a',
|
||||
correlationId: 'postgres-acquire-a',
|
||||
}),
|
||||
firstCoordinator.acquire({
|
||||
...command,
|
||||
connectorId: 'connector-b',
|
||||
correlationId: 'postgres-acquire-b',
|
||||
}),
|
||||
]);
|
||||
const acquired = contenders.find((result) => result.status === 'fulfilled');
|
||||
if (!acquired || acquired.status !== 'fulfilled') throw new Error('no lease contender won');
|
||||
expect(contenders.filter((result) => result.status === 'fulfilled')).toHaveLength(1);
|
||||
|
||||
await handle.close();
|
||||
handle = createDb(process.env['DATABASE_URL']);
|
||||
const reopened = new ConnectorLeaseCoordinator(new ConnectorLeaseRepository(handle.db));
|
||||
const persisted = await reopened.current({ identity, bindingId: 'operator-chat' });
|
||||
expect(persisted).toMatchObject({
|
||||
leaseId: acquired.value.leaseId,
|
||||
leaseEpoch: '1',
|
||||
});
|
||||
|
||||
const takeover = await reopened.takeover({
|
||||
...command,
|
||||
connectorId: 'connector-c',
|
||||
correlationId: 'postgres-takeover',
|
||||
expectedEpoch: acquired.value.leaseEpoch,
|
||||
});
|
||||
expect(takeover).toMatchObject({ connectorId: 'connector-c', leaseEpoch: '2' });
|
||||
});
|
||||
});
|
||||
@@ -1,149 +0,0 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
connectorLeaseAuditLog,
|
||||
createPgliteDb,
|
||||
eq,
|
||||
runPgliteMigrations,
|
||||
type DbHandle,
|
||||
} from '@mosaicstack/db';
|
||||
import { ConnectorLeaseCoordinator, ConnectorLeaseError } from '@mosaicstack/agent';
|
||||
import { ConnectorLeaseRepository } from './connector-lease.repository.js';
|
||||
|
||||
const identity = { tenantId: 'tenant-a', logicalAgentId: 'mos' } as const;
|
||||
|
||||
function acquireCommand(connectorId: string, correlationId: string) {
|
||||
return {
|
||||
identity,
|
||||
bindingId: 'operator-chat',
|
||||
connectorId,
|
||||
scopes: ['runtime.send', 'tool.execute'],
|
||||
ttlMs: 60_000,
|
||||
correlationId,
|
||||
};
|
||||
}
|
||||
|
||||
describe('ConnectorLeaseRepository PostgreSQL semantics', (): void => {
|
||||
let dataDir: string;
|
||||
let handle: DbHandle;
|
||||
let now: Date;
|
||||
let coordinator: ConnectorLeaseCoordinator;
|
||||
|
||||
beforeEach(async (): Promise<void> => {
|
||||
dataDir = await mkdtemp(join(tmpdir(), 'mosaic-connector-lease-'));
|
||||
handle = createPgliteDb(dataDir);
|
||||
await runPgliteMigrations(handle);
|
||||
now = new Date('2026-07-14T17:00:00.000Z');
|
||||
coordinator = new ConnectorLeaseCoordinator(new ConnectorLeaseRepository(handle.db), {
|
||||
now: (): Date => now,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async (): Promise<void> => {
|
||||
await handle.close();
|
||||
await rm(dataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('allows only one concurrent contender to acquire a binding', async (): Promise<void> => {
|
||||
const outcomes = await Promise.allSettled([
|
||||
coordinator.acquire(acquireCommand('connector-a', 'correlation-a')),
|
||||
coordinator.acquire(acquireCommand('connector-b', 'correlation-b')),
|
||||
]);
|
||||
|
||||
expect(outcomes.filter((result) => result.status === 'fulfilled')).toHaveLength(1);
|
||||
const rejected = outcomes.find((result) => result.status === 'rejected');
|
||||
expect(rejected).toMatchObject({
|
||||
reason: { code: 'lease_held' } satisfies Partial<ConnectorLeaseError>,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses compare-and-swap takeover and increments the fencing epoch monotonically', async (): Promise<void> => {
|
||||
const acquired = await coordinator.acquire(acquireCommand('connector-a', 'correlation-a'));
|
||||
const results = await Promise.allSettled([
|
||||
coordinator.takeover({
|
||||
...acquireCommand('connector-b', 'correlation-b'),
|
||||
expectedEpoch: acquired.leaseEpoch,
|
||||
}),
|
||||
coordinator.takeover({
|
||||
...acquireCommand('connector-c', 'correlation-c'),
|
||||
expectedEpoch: acquired.leaseEpoch,
|
||||
}),
|
||||
]);
|
||||
const winner = results.find((result) => result.status === 'fulfilled');
|
||||
|
||||
expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1);
|
||||
expect(winner?.status === 'fulfilled' ? winner.value.leaseEpoch : null).toBe('2');
|
||||
expect(results.find((result) => result.status === 'rejected')).toMatchObject({
|
||||
reason: { code: 'cas_mismatch' } satisfies Partial<ConnectorLeaseError>,
|
||||
});
|
||||
});
|
||||
|
||||
it('heartbeats and releases only the current connector epoch', async (): Promise<void> => {
|
||||
const acquired = await coordinator.acquire(acquireCommand('connector-a', 'correlation-a'));
|
||||
now = new Date('2026-07-14T17:00:30.000Z');
|
||||
const renewed = await coordinator.heartbeat({
|
||||
lease: acquired,
|
||||
ttlMs: 120_000,
|
||||
correlationId: 'correlation-renew',
|
||||
});
|
||||
expect(renewed.expiresAt).toBe('2026-07-14T17:02:30.000Z');
|
||||
|
||||
await coordinator.release({ lease: renewed, correlationId: 'correlation-release' });
|
||||
await expect(
|
||||
coordinator.heartbeat({
|
||||
lease: renewed,
|
||||
ttlMs: 120_000,
|
||||
correlationId: 'correlation-stale',
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 'lease_released' } satisfies Partial<ConnectorLeaseError>);
|
||||
});
|
||||
|
||||
it('survives close/reopen and requires CAS takeover to recover an expired lease', async (): Promise<void> => {
|
||||
const acquired = await coordinator.acquire(acquireCommand('connector-a', 'correlation-a'));
|
||||
await handle.close();
|
||||
|
||||
now = new Date('2026-07-14T17:02:00.000Z');
|
||||
handle = createPgliteDb(dataDir);
|
||||
await runPgliteMigrations(handle);
|
||||
coordinator = new ConnectorLeaseCoordinator(new ConnectorLeaseRepository(handle.db), {
|
||||
now: (): Date => now,
|
||||
});
|
||||
|
||||
await expect(
|
||||
coordinator.acquire(acquireCommand('connector-b', 'correlation-plain-acquire')),
|
||||
).rejects.toMatchObject({ code: 'takeover_required' } satisfies Partial<ConnectorLeaseError>);
|
||||
const recovered = await coordinator.takeover({
|
||||
...acquireCommand('connector-b', 'correlation-takeover'),
|
||||
expectedEpoch: acquired.leaseEpoch,
|
||||
});
|
||||
expect(recovered).toMatchObject({ connectorId: 'connector-b', leaseEpoch: '2' });
|
||||
});
|
||||
|
||||
it('writes credential-safe lifecycle and rejection audit records', async (): Promise<void> => {
|
||||
const acquired = await coordinator.acquire(acquireCommand('connector-a', 'correlation-a'));
|
||||
await coordinator.heartbeat({
|
||||
lease: acquired,
|
||||
ttlMs: 60_000,
|
||||
correlationId: 'correlation-renew',
|
||||
});
|
||||
await expect(
|
||||
coordinator.acquire(acquireCommand('connector-b', 'correlation-reject')),
|
||||
).rejects.toBeInstanceOf(ConnectorLeaseError);
|
||||
|
||||
const rows = await handle.db
|
||||
.select()
|
||||
.from(connectorLeaseAuditLog)
|
||||
.where(eq(connectorLeaseAuditLog.tenantId, identity.tenantId));
|
||||
expect(rows.map((row) => row.event)).toEqual(
|
||||
expect.arrayContaining(['acquire', 'renew', 'reject']),
|
||||
);
|
||||
const serialized = JSON.stringify(rows, (_key: string, value: unknown): unknown =>
|
||||
typeof value === 'bigint' ? value.toString(10) : value,
|
||||
);
|
||||
expect(serialized).not.toContain('tool.execute');
|
||||
expect(serialized).not.toContain('runtime.send');
|
||||
expect(serialized).not.toMatch(/token|secret|credential/i);
|
||||
});
|
||||
});
|
||||
@@ -1,354 +0,0 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import {
|
||||
and,
|
||||
connectorLeaseAuditLog,
|
||||
eq,
|
||||
gt,
|
||||
isNull,
|
||||
logicalAgentConnectorLeases,
|
||||
sql,
|
||||
type Db,
|
||||
} from '@mosaicstack/db';
|
||||
import { ConnectorLeaseError } from '@mosaicstack/agent';
|
||||
import type {
|
||||
ConnectorLease,
|
||||
ConnectorLeaseAcquireMutation,
|
||||
ConnectorLeaseAuditEvent,
|
||||
ConnectorLeaseHeartbeatMutation,
|
||||
ConnectorLeaseRejectReason,
|
||||
ConnectorLeaseReleaseMutation,
|
||||
ConnectorLeaseStore,
|
||||
ConnectorLeaseTakeoverMutation,
|
||||
LogicalAgentBinding,
|
||||
} from '@mosaicstack/types';
|
||||
import { DB } from '../database/database.module.js';
|
||||
|
||||
interface SuccessfulMutation {
|
||||
readonly ok: true;
|
||||
readonly lease: ConnectorLease;
|
||||
}
|
||||
|
||||
interface FailedMutation {
|
||||
readonly ok: false;
|
||||
readonly reason: ConnectorLeaseRejectReason;
|
||||
}
|
||||
|
||||
type MutationResult = SuccessfulMutation | FailedMutation;
|
||||
|
||||
@Injectable()
|
||||
export class ConnectorLeaseRepository implements ConnectorLeaseStore {
|
||||
constructor(@Inject(DB) private readonly db: Db) {}
|
||||
|
||||
async acquire(input: ConnectorLeaseAcquireMutation): Promise<ConnectorLease> {
|
||||
const result: MutationResult = await this.db.transaction(
|
||||
async (tx): Promise<MutationResult> => {
|
||||
const inserted = await tx
|
||||
.insert(logicalAgentConnectorLeases)
|
||||
.values({
|
||||
leaseId: input.leaseId,
|
||||
tenantId: input.identity.tenantId,
|
||||
logicalAgentId: input.identity.logicalAgentId,
|
||||
bindingId: input.bindingId,
|
||||
connectorId: input.connectorId,
|
||||
scopes: [...input.scopes],
|
||||
leaseEpoch: 1n,
|
||||
acquiredAt: new Date(input.now),
|
||||
heartbeatAt: new Date(input.now),
|
||||
expiresAt: new Date(input.expiresAt),
|
||||
updatedAt: new Date(input.now),
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.returning();
|
||||
const row = inserted[0];
|
||||
if (row) {
|
||||
const lease = toLease(row);
|
||||
await insertAudit(tx, lifecycleAudit(input, lease, 'acquire'));
|
||||
return { ok: true, lease };
|
||||
}
|
||||
|
||||
const current = await findRow(tx, input);
|
||||
if (current && current.expiresAt <= new Date(input.now) && !current.releasedAt) {
|
||||
await insertAudit(tx, lifecycleAudit(input, toLease(current), 'expiry'));
|
||||
}
|
||||
const reason: ConnectorLeaseRejectReason =
|
||||
current && (current.releasedAt || current.expiresAt <= new Date(input.now))
|
||||
? 'takeover_required'
|
||||
: 'lease_held';
|
||||
await insertAudit(tx, rejectionAudit(input, current ? toLease(current) : null, reason));
|
||||
return { ok: false, reason };
|
||||
},
|
||||
);
|
||||
return unwrap(result);
|
||||
}
|
||||
|
||||
async takeover(input: ConnectorLeaseTakeoverMutation): Promise<ConnectorLease> {
|
||||
const result: MutationResult = await this.db.transaction(
|
||||
async (tx): Promise<MutationResult> => {
|
||||
const current = await findRow(tx, input);
|
||||
if (!current || current.leaseEpoch.toString(10) !== input.expectedEpoch) {
|
||||
await insertAudit(
|
||||
tx,
|
||||
rejectionAudit(input, current ? toLease(current) : null, 'cas_mismatch'),
|
||||
);
|
||||
return { ok: false, reason: 'cas_mismatch' };
|
||||
}
|
||||
if (current.expiresAt <= new Date(input.now) && !current.releasedAt) {
|
||||
await insertAudit(tx, lifecycleAudit(input, toLease(current), 'expiry'));
|
||||
}
|
||||
const updated = await tx
|
||||
.update(logicalAgentConnectorLeases)
|
||||
.set({
|
||||
leaseId: input.leaseId,
|
||||
connectorId: input.connectorId,
|
||||
scopes: [...input.scopes],
|
||||
leaseEpoch: sql`${logicalAgentConnectorLeases.leaseEpoch} + 1`,
|
||||
acquiredAt: new Date(input.now),
|
||||
heartbeatAt: new Date(input.now),
|
||||
expiresAt: new Date(input.expiresAt),
|
||||
releasedAt: null,
|
||||
updatedAt: new Date(input.now),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
bindingPredicate(input),
|
||||
eq(logicalAgentConnectorLeases.leaseId, current.leaseId),
|
||||
eq(logicalAgentConnectorLeases.leaseEpoch, BigInt(input.expectedEpoch)),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
const row = updated[0];
|
||||
if (!row) {
|
||||
await insertAudit(tx, rejectionAudit(input, toLease(current), 'cas_mismatch'));
|
||||
return { ok: false, reason: 'cas_mismatch' };
|
||||
}
|
||||
const lease = toLease(row);
|
||||
await insertAudit(tx, lifecycleAudit(input, lease, 'takeover'));
|
||||
return { ok: true, lease };
|
||||
},
|
||||
);
|
||||
return unwrap(result);
|
||||
}
|
||||
|
||||
async heartbeat(input: ConnectorLeaseHeartbeatMutation): Promise<ConnectorLease> {
|
||||
const result: MutationResult = await this.db.transaction(
|
||||
async (tx): Promise<MutationResult> => {
|
||||
const updated = await tx
|
||||
.update(logicalAgentConnectorLeases)
|
||||
.set({
|
||||
heartbeatAt: new Date(input.now),
|
||||
expiresAt: new Date(input.expiresAt),
|
||||
updatedAt: new Date(input.now),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
bindingPredicate(input.lease),
|
||||
eq(logicalAgentConnectorLeases.leaseId, input.lease.leaseId),
|
||||
eq(logicalAgentConnectorLeases.connectorId, input.lease.connectorId),
|
||||
eq(logicalAgentConnectorLeases.leaseEpoch, BigInt(input.lease.leaseEpoch)),
|
||||
isNull(logicalAgentConnectorLeases.releasedAt),
|
||||
gt(logicalAgentConnectorLeases.expiresAt, new Date(input.now)),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
const row = updated[0];
|
||||
if (row) {
|
||||
const lease = toLease(row);
|
||||
await insertAudit(tx, lifecycleAudit(input, lease, 'renew'));
|
||||
return { ok: true, lease };
|
||||
}
|
||||
const current = await findRow(tx, input.lease);
|
||||
const reason = classifyAuthorityFailure(
|
||||
current ? toLease(current) : null,
|
||||
input.lease,
|
||||
input.now,
|
||||
);
|
||||
if (reason === 'lease_expired' && current) {
|
||||
await insertAudit(tx, lifecycleAudit(input, toLease(current), 'expiry'));
|
||||
}
|
||||
await insertAudit(
|
||||
tx,
|
||||
rejectionAudit(
|
||||
{ ...input.lease, correlationId: input.correlationId, now: input.now },
|
||||
current ? toLease(current) : null,
|
||||
reason,
|
||||
),
|
||||
);
|
||||
return { ok: false, reason };
|
||||
},
|
||||
);
|
||||
return unwrap(result);
|
||||
}
|
||||
|
||||
async release(input: ConnectorLeaseReleaseMutation): Promise<void> {
|
||||
const result: MutationResult = await this.db.transaction(
|
||||
async (tx): Promise<MutationResult> => {
|
||||
const updated = await tx
|
||||
.update(logicalAgentConnectorLeases)
|
||||
.set({
|
||||
releasedAt: new Date(input.now),
|
||||
expiresAt: new Date(input.now),
|
||||
updatedAt: new Date(input.now),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
bindingPredicate(input.lease),
|
||||
eq(logicalAgentConnectorLeases.leaseId, input.lease.leaseId),
|
||||
eq(logicalAgentConnectorLeases.connectorId, input.lease.connectorId),
|
||||
eq(logicalAgentConnectorLeases.leaseEpoch, BigInt(input.lease.leaseEpoch)),
|
||||
isNull(logicalAgentConnectorLeases.releasedAt),
|
||||
gt(logicalAgentConnectorLeases.expiresAt, new Date(input.now)),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
const row = updated[0];
|
||||
if (row) {
|
||||
const lease = toLease(row);
|
||||
await insertAudit(tx, lifecycleAudit(input, lease, 'release'));
|
||||
return { ok: true, lease };
|
||||
}
|
||||
const current = await findRow(tx, input.lease);
|
||||
const reason = classifyAuthorityFailure(
|
||||
current ? toLease(current) : null,
|
||||
input.lease,
|
||||
input.now,
|
||||
);
|
||||
if (reason === 'lease_expired' && current) {
|
||||
await insertAudit(tx, lifecycleAudit(input, toLease(current), 'expiry'));
|
||||
}
|
||||
await insertAudit(
|
||||
tx,
|
||||
rejectionAudit(
|
||||
{ ...input.lease, correlationId: input.correlationId, now: input.now },
|
||||
current ? toLease(current) : null,
|
||||
reason,
|
||||
),
|
||||
);
|
||||
return { ok: false, reason };
|
||||
},
|
||||
);
|
||||
unwrap(result);
|
||||
}
|
||||
|
||||
async findCurrent(binding: LogicalAgentBinding): Promise<ConnectorLease | null> {
|
||||
const row = await findRow(this.db, binding);
|
||||
return row ? toLease(row) : null;
|
||||
}
|
||||
|
||||
async recordAudit(event: ConnectorLeaseAuditEvent): Promise<void> {
|
||||
await insertAudit(this.db, event);
|
||||
}
|
||||
}
|
||||
|
||||
function unwrap(result: MutationResult): ConnectorLease {
|
||||
if (!result.ok) throw new ConnectorLeaseError(result.reason, safeErrorMessage(result.reason));
|
||||
return result.lease;
|
||||
}
|
||||
|
||||
function safeErrorMessage(reason: ConnectorLeaseRejectReason): string {
|
||||
return `Connector lease mutation denied: ${reason}`;
|
||||
}
|
||||
|
||||
function bindingPredicate(binding: LogicalAgentBinding) {
|
||||
return and(
|
||||
eq(logicalAgentConnectorLeases.tenantId, binding.identity.tenantId),
|
||||
eq(logicalAgentConnectorLeases.logicalAgentId, binding.identity.logicalAgentId),
|
||||
eq(logicalAgentConnectorLeases.bindingId, binding.bindingId),
|
||||
);
|
||||
}
|
||||
|
||||
async function findRow(
|
||||
db: Pick<Db, 'select'>,
|
||||
binding: LogicalAgentBinding,
|
||||
): Promise<typeof logicalAgentConnectorLeases.$inferSelect | null> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(logicalAgentConnectorLeases)
|
||||
.where(bindingPredicate(binding))
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
function toLease(row: typeof logicalAgentConnectorLeases.$inferSelect): ConnectorLease {
|
||||
return Object.freeze({
|
||||
identity: Object.freeze({ tenantId: row.tenantId, logicalAgentId: row.logicalAgentId }),
|
||||
bindingId: row.bindingId,
|
||||
leaseId: row.leaseId,
|
||||
connectorId: row.connectorId,
|
||||
scopes: Object.freeze([...row.scopes]),
|
||||
leaseEpoch: row.leaseEpoch.toString(10),
|
||||
acquiredAt: row.acquiredAt.toISOString(),
|
||||
heartbeatAt: row.heartbeatAt.toISOString(),
|
||||
expiresAt: row.expiresAt.toISOString(),
|
||||
...(row.releasedAt ? { releasedAt: row.releasedAt.toISOString() } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
function classifyAuthorityFailure(
|
||||
current: ConnectorLease | null,
|
||||
claimed: ConnectorLease,
|
||||
now: string,
|
||||
): ConnectorLeaseRejectReason {
|
||||
if (!current) return 'lease_missing';
|
||||
if (current.releasedAt) return 'lease_released';
|
||||
if (new Date(current.expiresAt) <= new Date(now)) return 'lease_expired';
|
||||
if (current.leaseEpoch !== claimed.leaseEpoch) return 'stale_epoch';
|
||||
return 'connector_mismatch';
|
||||
}
|
||||
|
||||
function lifecycleAudit(
|
||||
input: { readonly correlationId: string; readonly now: string },
|
||||
lease: ConnectorLease,
|
||||
event: Exclude<ConnectorLeaseAuditEvent['event'], 'reject'>,
|
||||
): ConnectorLeaseAuditEvent {
|
||||
return {
|
||||
identity: lease.identity,
|
||||
bindingId: lease.bindingId,
|
||||
connectorId: lease.connectorId,
|
||||
leaseId: lease.leaseId,
|
||||
leaseEpoch: lease.leaseEpoch,
|
||||
event,
|
||||
outcome: 'succeeded',
|
||||
correlationId: input.correlationId,
|
||||
occurredAt: input.now,
|
||||
};
|
||||
}
|
||||
|
||||
function rejectionAudit(
|
||||
input: {
|
||||
readonly identity: ConnectorLease['identity'];
|
||||
readonly bindingId: string;
|
||||
readonly connectorId: string;
|
||||
readonly correlationId: string;
|
||||
readonly now: string;
|
||||
},
|
||||
current: ConnectorLease | null,
|
||||
reason: ConnectorLeaseRejectReason,
|
||||
): ConnectorLeaseAuditEvent {
|
||||
return {
|
||||
identity: input.identity,
|
||||
bindingId: input.bindingId,
|
||||
connectorId: input.connectorId,
|
||||
event: 'reject',
|
||||
outcome: 'denied',
|
||||
correlationId: input.correlationId,
|
||||
occurredAt: input.now,
|
||||
...(current ? { leaseId: current.leaseId, leaseEpoch: current.leaseEpoch } : {}),
|
||||
reason,
|
||||
};
|
||||
}
|
||||
|
||||
async function insertAudit(db: Pick<Db, 'insert'>, event: ConnectorLeaseAuditEvent): Promise<void> {
|
||||
await db.insert(connectorLeaseAuditLog).values({
|
||||
tenantId: event.identity.tenantId,
|
||||
logicalAgentId: event.identity.logicalAgentId,
|
||||
bindingId: event.bindingId,
|
||||
connectorId: event.connectorId,
|
||||
...(event.leaseId ? { leaseId: event.leaseId } : {}),
|
||||
...(event.leaseEpoch ? { leaseEpoch: BigInt(event.leaseEpoch) } : {}),
|
||||
event: event.event,
|
||||
outcome: event.outcome,
|
||||
...(event.reason ? { reason: event.reason } : {}),
|
||||
correlationId: event.correlationId,
|
||||
occurredAt: new Date(event.occurredAt),
|
||||
});
|
||||
}
|
||||
@@ -1,285 +0,0 @@
|
||||
import { ForbiddenException, Inject, Injectable } from '@nestjs/common';
|
||||
import { ConnectorLeaseCoordinator, normalizeConnectorLease } from '@mosaicstack/agent';
|
||||
import {
|
||||
normalizeConnectorId,
|
||||
normalizeConnectorScopes,
|
||||
normalizeCorrelationId,
|
||||
normalizeLogicalAgentIdentity,
|
||||
normalizeLogicalBindingId,
|
||||
type AcquireConnectorLeaseInput,
|
||||
type ConnectorExecutionGrant,
|
||||
type ConnectorLease,
|
||||
type ConnectorLeaseAuditEvent,
|
||||
type FencedConnectorAdapter,
|
||||
} from '@mosaicstack/types';
|
||||
import type { ActorTenantScope } from '../auth/session-scope.js';
|
||||
import { ConnectorLeaseRepository } from './connector-lease.repository.js';
|
||||
|
||||
export const CONNECTOR_LEASE_POLICY = Symbol('CONNECTOR_LEASE_POLICY');
|
||||
|
||||
export type ConnectorLeasePolicyAction =
|
||||
| 'lease.acquire'
|
||||
| 'lease.takeover'
|
||||
| 'lease.heartbeat'
|
||||
| 'lease.release'
|
||||
| 'lease.read'
|
||||
| 'grant.issue';
|
||||
|
||||
export interface ConnectorLeaseRequestContext {
|
||||
readonly actorScope: ActorTenantScope;
|
||||
readonly correlationId: string;
|
||||
}
|
||||
|
||||
export interface GatewayConnectorLeaseRequest {
|
||||
readonly logicalAgentId: string;
|
||||
readonly bindingId: string;
|
||||
readonly connectorId: string;
|
||||
readonly scopes: readonly string[];
|
||||
readonly ttlMs: number;
|
||||
}
|
||||
|
||||
export interface GatewayConnectorLeaseTakeoverRequest extends GatewayConnectorLeaseRequest {
|
||||
readonly expectedEpoch: string;
|
||||
}
|
||||
|
||||
export interface GatewayConnectorGrantRequest {
|
||||
readonly lease: ConnectorLease;
|
||||
readonly scopes: readonly string[];
|
||||
readonly ttlMs: number;
|
||||
}
|
||||
|
||||
export interface ConnectorLeasePolicySubject {
|
||||
readonly action: ConnectorLeasePolicyAction;
|
||||
readonly actorId: string;
|
||||
readonly tenantId: string;
|
||||
readonly logicalAgentId: string;
|
||||
readonly bindingId: string;
|
||||
readonly connectorId: string;
|
||||
readonly requestedScopes: readonly string[];
|
||||
readonly requestedTtlMs: number | null;
|
||||
}
|
||||
|
||||
export interface ConnectorLeasePolicy {
|
||||
authorize(subject: ConnectorLeasePolicySubject): Promise<boolean>;
|
||||
}
|
||||
|
||||
/** M1 has no concrete cutover policy: unconfigured production use fails closed. */
|
||||
@Injectable()
|
||||
export class DenyConnectorLeasePolicy implements ConnectorLeasePolicy {
|
||||
async authorize(_subject: ConnectorLeasePolicySubject): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Gateway-owned policy surface for durable connector authority and fenced effects. */
|
||||
@Injectable()
|
||||
export class ConnectorLeaseService {
|
||||
private readonly coordinator: ConnectorLeaseCoordinator;
|
||||
|
||||
constructor(
|
||||
@Inject(ConnectorLeaseRepository) private readonly repository: ConnectorLeaseRepository,
|
||||
@Inject(CONNECTOR_LEASE_POLICY) private readonly policy: ConnectorLeasePolicy,
|
||||
) {
|
||||
this.coordinator = new ConnectorLeaseCoordinator(repository);
|
||||
}
|
||||
|
||||
async acquire(
|
||||
request: GatewayConnectorLeaseRequest,
|
||||
context: ConnectorLeaseRequestContext,
|
||||
): Promise<ConnectorLease> {
|
||||
const command = this.command(request, context);
|
||||
await this.assertPolicy('lease.acquire', command, context, command.scopes, command.ttlMs);
|
||||
return this.coordinator.acquire({ ...command, correlationId: this.correlation(context) });
|
||||
}
|
||||
|
||||
async takeover(
|
||||
request: GatewayConnectorLeaseTakeoverRequest,
|
||||
context: ConnectorLeaseRequestContext,
|
||||
): Promise<ConnectorLease> {
|
||||
const command = this.command(request, context);
|
||||
await this.assertPolicy('lease.takeover', command, context, command.scopes, command.ttlMs);
|
||||
return this.coordinator.takeover({
|
||||
...command,
|
||||
expectedEpoch: request.expectedEpoch,
|
||||
correlationId: this.correlation(context),
|
||||
});
|
||||
}
|
||||
|
||||
async heartbeat(
|
||||
lease: ConnectorLease,
|
||||
ttlMs: number,
|
||||
context: ConnectorLeaseRequestContext,
|
||||
): Promise<ConnectorLease> {
|
||||
const normalizedLease = normalizeConnectorLease(lease);
|
||||
const durableLease = await this.durableLifecycleLease(normalizedLease, context);
|
||||
await this.assertPolicy('lease.heartbeat', durableLease, context, durableLease.scopes, ttlMs);
|
||||
return this.coordinator.heartbeat({
|
||||
lease: durableLease,
|
||||
ttlMs,
|
||||
correlationId: this.correlation(context),
|
||||
});
|
||||
}
|
||||
|
||||
async release(lease: ConnectorLease, context: ConnectorLeaseRequestContext): Promise<void> {
|
||||
const normalizedLease = normalizeConnectorLease(lease);
|
||||
const durableLease = await this.durableLifecycleLease(normalizedLease, context);
|
||||
await this.assertPolicy('lease.release', durableLease, context, durableLease.scopes, null);
|
||||
await this.coordinator.release({
|
||||
lease: durableLease,
|
||||
correlationId: this.correlation(context),
|
||||
});
|
||||
}
|
||||
|
||||
async current(
|
||||
logicalAgentId: string,
|
||||
bindingId: string,
|
||||
context: ConnectorLeaseRequestContext,
|
||||
): Promise<ConnectorLease | null> {
|
||||
const binding = {
|
||||
identity: normalizeLogicalAgentIdentity({
|
||||
tenantId: context.actorScope.tenantId,
|
||||
logicalAgentId,
|
||||
}),
|
||||
bindingId: normalizeLogicalBindingId(bindingId),
|
||||
connectorId: 'gateway',
|
||||
};
|
||||
await this.assertPolicy('lease.read', binding, context, [], null);
|
||||
return this.coordinator.current(binding);
|
||||
}
|
||||
|
||||
async issueGrant(
|
||||
request: GatewayConnectorGrantRequest,
|
||||
context: ConnectorLeaseRequestContext,
|
||||
): Promise<ConnectorExecutionGrant> {
|
||||
const lease = normalizeConnectorLease(request.lease);
|
||||
await this.assertTenant(lease, context);
|
||||
const scopes = normalizeConnectorScopes(request.scopes);
|
||||
await this.assertPolicy('grant.issue', lease, context, scopes, request.ttlMs);
|
||||
return this.coordinator.issueGrant({
|
||||
lease,
|
||||
scopes,
|
||||
ttlMs: request.ttlMs,
|
||||
correlationId: this.correlation(context),
|
||||
});
|
||||
}
|
||||
|
||||
async executeGrant<TInput, TOutput>(
|
||||
grant: ConnectorExecutionGrant,
|
||||
requiredScope: string,
|
||||
input: TInput,
|
||||
adapter: FencedConnectorAdapter<TInput, TOutput>,
|
||||
): Promise<TOutput> {
|
||||
return this.coordinator.executeGrant(grant, requiredScope, input, adapter);
|
||||
}
|
||||
|
||||
private command(
|
||||
request: GatewayConnectorLeaseRequest,
|
||||
context: ConnectorLeaseRequestContext,
|
||||
): Omit<AcquireConnectorLeaseInput, 'correlationId'> {
|
||||
return {
|
||||
identity: normalizeLogicalAgentIdentity({
|
||||
tenantId: context.actorScope.tenantId,
|
||||
logicalAgentId: request.logicalAgentId,
|
||||
}),
|
||||
bindingId: normalizeLogicalBindingId(request.bindingId),
|
||||
connectorId: normalizeConnectorId(request.connectorId),
|
||||
scopes: normalizeConnectorScopes(request.scopes),
|
||||
ttlMs: request.ttlMs,
|
||||
};
|
||||
}
|
||||
|
||||
private async assertTenant(
|
||||
lease: Pick<ConnectorLease, 'identity' | 'bindingId' | 'connectorId'>,
|
||||
context: ConnectorLeaseRequestContext,
|
||||
): Promise<void> {
|
||||
if (lease.identity.tenantId !== context.actorScope.tenantId) {
|
||||
await this.recordPolicyDenial(
|
||||
{
|
||||
identity: {
|
||||
tenantId: context.actorScope.tenantId,
|
||||
logicalAgentId: 'untrusted',
|
||||
},
|
||||
bindingId: 'untrusted',
|
||||
connectorId: 'untrusted',
|
||||
},
|
||||
context,
|
||||
);
|
||||
throw new ForbiddenException('Connector authority tenant scope denied');
|
||||
}
|
||||
}
|
||||
|
||||
private async durableLifecycleLease(
|
||||
submittedLease: ConnectorLease,
|
||||
context: ConnectorLeaseRequestContext,
|
||||
): Promise<ConnectorLease> {
|
||||
await this.assertTenant(submittedLease, context);
|
||||
const durableLease = await this.coordinator.current(submittedLease);
|
||||
if (!durableLease || !hasSameLifecycleAuthority(submittedLease, durableLease)) {
|
||||
await this.recordPolicyDenial(durableLease ?? submittedLease, context);
|
||||
throw new ForbiddenException('Connector authority policy denied');
|
||||
}
|
||||
return durableLease;
|
||||
}
|
||||
|
||||
private async assertPolicy(
|
||||
action: ConnectorLeasePolicyAction,
|
||||
subject: Pick<ConnectorLease, 'identity' | 'bindingId' | 'connectorId'>,
|
||||
context: ConnectorLeaseRequestContext,
|
||||
requestedScopes: readonly string[],
|
||||
requestedTtlMs: number | null,
|
||||
): Promise<void> {
|
||||
const allowed = await this.policy.authorize({
|
||||
action,
|
||||
actorId: context.actorScope.userId,
|
||||
tenantId: subject.identity.tenantId,
|
||||
logicalAgentId: subject.identity.logicalAgentId,
|
||||
bindingId: subject.bindingId,
|
||||
connectorId: subject.connectorId,
|
||||
requestedScopes: Object.freeze([...requestedScopes]),
|
||||
requestedTtlMs,
|
||||
});
|
||||
if (!allowed) {
|
||||
await this.recordPolicyDenial(subject, context);
|
||||
throw new ForbiddenException('Connector authority policy denied');
|
||||
}
|
||||
}
|
||||
|
||||
private async recordPolicyDenial(
|
||||
subject: Pick<ConnectorLease, 'identity' | 'bindingId' | 'connectorId'>,
|
||||
context: ConnectorLeaseRequestContext,
|
||||
): Promise<void> {
|
||||
const event: ConnectorLeaseAuditEvent = {
|
||||
identity: subject.identity,
|
||||
bindingId: subject.bindingId,
|
||||
connectorId: subject.connectorId,
|
||||
event: 'reject',
|
||||
outcome: 'denied',
|
||||
reason: 'policy_denied',
|
||||
correlationId: this.correlation(context),
|
||||
occurredAt: new Date().toISOString(),
|
||||
};
|
||||
await this.repository.recordAudit(event);
|
||||
}
|
||||
|
||||
private correlation(context: ConnectorLeaseRequestContext): string {
|
||||
return normalizeCorrelationId(context.correlationId);
|
||||
}
|
||||
}
|
||||
|
||||
function hasSameLifecycleAuthority(
|
||||
submittedLease: ConnectorLease,
|
||||
durableLease: ConnectorLease,
|
||||
): boolean {
|
||||
return (
|
||||
submittedLease.identity.tenantId === durableLease.identity.tenantId &&
|
||||
submittedLease.identity.logicalAgentId === durableLease.identity.logicalAgentId &&
|
||||
submittedLease.bindingId === durableLease.bindingId &&
|
||||
submittedLease.leaseId === durableLease.leaseId &&
|
||||
submittedLease.connectorId === durableLease.connectorId &&
|
||||
submittedLease.leaseEpoch === durableLease.leaseEpoch &&
|
||||
submittedLease.scopes.length === durableLease.scopes.length &&
|
||||
submittedLease.scopes.every((scope: string, index: number): boolean => {
|
||||
return scope === durableLease.scopes[index];
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -149,9 +149,15 @@ for any `<Image>` components added in the future.
|
||||
|
||||
---
|
||||
|
||||
## Held future procedure
|
||||
## How to Apply
|
||||
|
||||
This report is non-operative evidence, not a current runbook. Until **KBN-101-00, KBN-101-03, and KBN-101-05** land, do not execute a PostgreSQL runner from this checkout. The approved future procedure is exactly: external bootstrap → TLS/roles → `mosaic-db-migrator --run` → `mosaic-db-migrator --verify` → Gateway/Compose readiness. Deployment will supply the reviewed runner, migration-only credentials, and TLS material; Gateway startup only verifies readiness.
|
||||
```bash
|
||||
# Run the DB migration (requires a live DB)
|
||||
pnpm --filter @mosaicstack/db exec drizzle-kit migrate
|
||||
|
||||
# Or, in Docker/Swarm — migrations run automatically on gateway startup
|
||||
# via runMigrations() in packages/db/src/migrate.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
68
docs/PRD.md
68
docs/PRD.md
@@ -125,35 +125,6 @@ are defined in [docs/TASKS.md](./TASKS.md) and must remain one card/one PR.
|
||||
|
||||
---
|
||||
|
||||
## KBN-101 Database Runtime/Migration Role Split (#771)
|
||||
|
||||
### Problem and objective
|
||||
|
||||
PostgreSQL Gateway/storage currently uses one `DATABASE_URL` for runtime queries and migrations. That makes the deployed application identity an owner and prevents certification that KBN immutable event, artifact, checkpoint, and evidence relations reject runtime `UPDATE`/`DELETE`. KBN-101 freezes a least-privilege runtime/migration split before KBN-100 schema work.
|
||||
|
||||
### Normative requirements
|
||||
|
||||
1. `K101-REQ-01`: `DATABASE_URL` SHALL be the non-owner PostgreSQL runtime connection and `DATABASE_MIGRATION_URL` SHALL be the migration-only owner/migrator connection. They are required respectively for runtime and the dedicated `mosaic-db-migrator --run|--verify` phase in `standalone`/`federated`; local PGlite is the explicit exception. The published `@mosaicstack/db` bin maps exactly `mosaic-db-migrator` to `./dist/cli.js`, its image entrypoint is exactly `mosaic-db-migrator`, accepts no URL/SQL/schema/role argv, and returns stable sanitized exits. Every current/future PostgreSQL DDL entrypoint SHALL route to that runner or be denied, and SHALL reject `DATABASE_URL`-only execution before connection/DDL. Data migration may connect only after the runner prepares and verifies the PostgreSQL target, through dedicated non-DDL `mosaic_data_importer` and exactly `--target-url-file /run/secrets/mosaic-migrate-target-url`, its fixed paired authenticated provider-version file `/run/secrets/mosaic-migrate-target-version`, plus `--target-attestation-file /run/mosaic-attestations/migrate-target.v1.json`. KBN-101-05 obtains URL key `url` and version only from the same successful Vault KV-v2 response at `secret-{env}/mosaic-stack/database/importer` (`data.metadata.version`), renders them as one immutable generation into separate consumer copies, and never infers a provider version from DSN bytes. The trusted runner verifies TLS/identity/manifest, reads its fixed importer URL/version copies only for binding through safe no-follow fd checks, and signs a credential-free JCS/Ed25519 attestation using its runner-only fixed root-owned private-key file; no signing key reaches importer/runtime. The artifact binds secret version and SHA-256 of exact high-entropy credential-file bytes, canonical TLS host/port/database, CA/SPKI, PostgreSQL system identifier/database OID, importer role, manifest/schema fingerprints, producer invocation/build/image digest, issued/expires/nonce, and correlation. Before target connection the importer validates URL/version/attestation/public-key files, signature/key/expiry/replay/authenticated provider version/digest/generation/bindings and the importer-only CA at exact `DATABASE_TLS_CA_CERT_PATH`; after verified TLS and before DML it validates server/database/role/CA/schema identity, with same-fd/in-memory-byte TOCTOU protection, rotation/revocation, a privileged producer-only-to-importer-only artifact handoff controller that verifies/copies/fsyncs/atomically renames/seals before importer start, consumer isolation/no logging-oracle, and sanitized errors. Raw `--target-url`, `DATABASE_URL` fallback, runtime-owner use, missing/unsafe/substituted files, stale/replayed/tampered/wrong-key attestation, wrong binding, and DDL attempt fail before target connection/DDL; post-connect mismatch closes with zero DML/DDL. A reviewed finite classifier inventories executable current source/scripts/package bins, operator docs, deploy manifests, and exact normative contracts by path; active secure records pin both options/files, producer/key/bindings/tests, while normative contracts cannot mask instructions. Unknown active commands, duplicate-owner, ownerless, missing-path, and historical/status-only masking hits fail. `db:push` is forbidden outside an explicitly disposable local developer database and cannot accept a production-like URL.
|
||||
2. `K101-REQ-02`: Gateway runtime/replicas SHALL not execute migrations or DDL. The runner SHALL hold one `max:1` session and fixed two-int advisory namespace `1297044289` (`MOSA`), `1262636593` (`KBN1`) across preflight, reconciliation, migration, verification, and release. It SHALL compare the versioned canonical manifest v1 tuple (journal logical index/tag plus exact SQL-byte SHA-256) to the complete observed ledger mapping; count/set-only, timestamps, and physical insertion order are non-normative and insufficient.
|
||||
3. `K101-REQ-03`: PostgreSQL SHALL separate non-login platform database owner, non-login schema owner, dedicated `NOLOGIN SUPERUSER` `mosaic_extension_owner`, login migrator, dedicated login non-DDL data importer, non-login runtime capability, and login runtime roles. For PostgreSQL 17 + pgvector 0.8.2, `vector` is untrusted (`trusted` is absent and `relocatable=true`): only an externally controlled audited platform-bootstrap superuser session may `SET ROLE mosaic_extension_owner` for CREATE/UPDATE/SET SCHEMA, then `RESET ROLE`; the role has `rolcanlogin=false`, `rolsuper=true`, zero members, no runtime credential/Vault secret, and is never provided to app containers. It owns `mosaic_extensions`, fresh `vector`, and owner-bearing extension members, while `mosaic_schema_owner` receives only `USAGE` for type resolution and never ownership/`CREATE`/`ALTER`/`DROP`/member-change/default-privilege authority there. Superuser cannot be constrained by `GRANT`/`REVOKE`; this is identity/non-login/no-membership/external-control/audit isolation, not a false least-privilege claim. Extension operations require control-plane change, independent review, backup/rollback, maintenance window, and audit evidence. Managed targets that cannot establish this exact role are ineligible until an independently approved versioned provider-owned extension-owner profile exists; app/migrator ownership is never silently retained. Existing approved-owner extension relocation validates exact `pg_namespace.nspowner`, `pg_extension.extowner`, member ownership/schema/version, while legacy runtime-owned extension fails closed to a controlled shadow-database migration—never unsupported ownership alteration, catalog mutation, ownership adoption, or `DROP CASCADE`. Runtime, migrator, schema owner, importer, and all service roles must fail `SET ROLE`, catalog/direct `ALTER`/`UPDATE`/`DROP`/membership-change denial, role ownership, superuser/role-creation/schema-creation/TEMPORARY, unsafe membership, untrusted search path, missing grants, unauthenticated TLS, and immutable privilege drift checks. Application schema is fixed `mosaic` with exact `pg_catalog,mosaic` session path; historical public migrations remain byte-immutable legacy bootstrap only, every future Drizzle application declaration targets `mosaic`, and `vector` is explicitly qualified from non-writable `mosaic_extensions`. No config-derived SQL identifier is permitted.
|
||||
4. `K101-REQ-04`: `mosaicstack/stack` KBN-101-00 SHALL exclusively own `infra/pg-bootstrap/roles.sql`, `infra/pg-bootstrap/extensions.sql`, `infra/pg-bootstrap/README.md`, and bootstrap tests; KBN-101-05 SHALL exclusively own `tools/db/render-postgres-secrets.ts`, its tests, and current Compose/Portainer/two-gateway deployment declarations, consuming the versioned bootstrap interface without overlap. Environment IaC/Vault is named input and Mosaic deployment control plane/Jason is activation authority. Distinct runtime/migrator/importer URL, importer authenticated provider-version, DB-client CA, Gateway leaf, and PostgreSQL server key/certificate materials are provisioned before a production-like database starts. Importer and migrator have separate immutable URL/version copies at fixed `10002:10002`/`10003:10003` identities; runtime/unrelated containers receive neither importer material, attestation private key, or importer artifact. Runtime, migrator, and importer require their mounted CA plus `sslmode=verify-full`. Exact UID/GID/mode/rendering, service-DNS SANs, Vault/compose/Swarm consumer isolation, two-gateway pair ordering, server activation, pre-enforcement legacy-client drain and `hostssl` zero-plaintext-session proof, fresh/existing transition, CA-overlap rotation, TLS-only rollback, and standalone/federated/Swarm/two-gateway positive/negative TLS evidence are required. No application-generated production certificate or plaintext bootstrap exception is permitted.
|
||||
5. `K101-REQ-05`: KBN immutable relations SHALL permit the real runtime role INSERT/SELECT only and deny UPDATE/DELETE; parent retention remains RESTRICT/no-cascade. Role/password/Vault creation is external platform control, never application migration/source.
|
||||
6. `K101-REQ-06`: N-1 single-URL compatibility, rollout/rollback, Vault ownership/rotation/redaction, CI, installer, compose/Portainer, observability, and deployment handoffs SHALL be separately bounded one-card/one-PR work. Prepared slices remain inactive while current owner-runtime deployments stay N-1; Mosaic control plane/Jason alone authorizes one final atomic activation or rollback, with no force-on-red/bypass. KBN-101 planning itself SHALL not mutate production.
|
||||
7. `K101-REQ-07`: KBN-100 SHALL begin only after the KBN-101 foundation role/schema-boundary certificate; it SHALL rebase on that main head, restore generated Drizzle declaration/snapshot/journal consistency, and bound procedural immutable-table grant/trigger/backfill additions to its schema slice. KBN-101 real deployed-role immutable-operation certification SHALL complete after KBN-100 creates those relations and before KBN-105.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
1. `AC-K101-01`: DTO/command-matrix tests prove required modes, PGlite exception, `mosaic-db-migrator --help|--run|--verify`/stable exits/argv refusal, public-import negative, every finite classified DDL/static-bypass inventory path and both harness pairs reject `DATABASE_URL`-only before connection/DDL, no migration-to-runtime fallback, and `db:push` refusal outside an allowlisted disposable DB. Before inventory, ownership, or status masking, the semantic fixture fails README's exact former commented code-fence generic-wrapper form and the user guide's exact former executable generic-wrapper form; source-consistency proves current `packages/storage/src/cli.ts` directly `execSync`s `pnpm --filter @mosaicstack/db db:migrate` and no `mosaic-db-migrator` bin exists, so runner-delegation documentation fails. The active `docs/guides/migrate-tier.md` route is inventoried to KBN-101-07 and proves runner-produced `--target-url-file /run/secrets/mosaic-migrate-target-url`, fixed paired provider-version file, and `--target-attestation-file /run/mosaic-attestations/migrate-target.v1.json`; runner-only signing/private-key isolation; Vault KV-v2 same-response version provenance, separate immutable generation mounts, importer CA, JCS/Ed25519 signature/key rotation/revocation, atomic artifact, expiry/replay, safe-fd secret-version/digest, canonical TLS/CA/server/database/role/manifest/schema bindings, dedicated non-DDL importer, consumer isolation/no log-oracle, and exact no-connection versus zero-DML rejection for missing/wrong/stale/replayed/tampered/wrong-key/substituted/generation-mismatched inputs. The full current non-normative docs inventory—including user guide, federation historical task/MILESTONES status, and non-operative SETUP—has an exact safe disposition. Scanner semantic checks reject automatic first-boot/startup extension/schema/migration wording, Compose-up-before-runner, init-script authority, production `.env`/monorepo auto-load/`EnvironmentFile=`/credential-export-or-argv/restart-as-secret-activation routes, and every unqualified operator-document `mosaic-db-migrator --run|--verify` hit regardless of named/normative/status classification. The exact former README/dev/deployment Compose-first sequences, former SETUP wording, exact former MILESTONES wording `pgvector extension installed + verified on startup`, former architecture-plan/PERFORMANCE/backlog runner routes, and any unqualified runner fixture fail before inventory masking. Only one `Held future procedure` Markdown section—bounded through the next equal-or-higher heading—may contain the explicit non-operative/no-current-command-authority form that names KBN-101-00/-03/-05 and preserves external bootstrap → TLS/roles → `mosaic-db-migrator --run` → `mosaic-db-migrator --verify` → Gateway/Compose readiness; every runner hit outside that section fails. The README assertion for the checked-in direct CI `pnpm --filter @mosaicstack/db run db:migrate` with `DATABASE_URL` passes only as active legacy N-1, uncertified, non-authorizing-as-an-operator-route status against an isolated disposable CI database pending KBN-101-06 removal—not as an ordinary operator or approved DDL-authority route. Only local PGlite data-layer work or non-PostgreSQL Compose is current (Gateway/Web local startup is held pending daemon/inherited/project-DSN rejection).
|
||||
2. `AC-K101-02`: Fixed namespace lock contention/crash/readiness/non-interference and exact manifest-v1 reconciliation tests prove no replica race/runtime auto-migration and fail closed on every missing/unknown/duplicate/ambiguous/corrupt/stale ledger state.
|
||||
3. `AC-K101-03`: Actual PostgreSQL 17 + pgvector 0.8.2 control-file, catalog, Drizzle-generation, vector-query/operator, fresh/approved-owner/legacy-shadow/partial/resume/rollback/N-1, and real deployed-role tests prove `trusted` absent/untrusted plus relocatability, external-superuser `SET ROLE` create/update/`RESET ROLE` audit, exact `rolcanlogin=false`/`rolsuper=true`/zero-membership/no-runtime-secret state, platform/schema/extension-owner/migrator/importer/runtime separation, `pg_extension.extowner` plus owner-bearing extension-member/schema/version assertions, and runtime/migrator/schema-owner/importer/all-service-role `SET ROLE`/ALTER/DROP/member-update denial. They also prove `pg_catalog,mosaic` per-session pool safety, `mosaic_extensions` qualification, identifier injection denial, ownership/membership/ledger-read/TEMP/default grants, and unsafe privilege denial.
|
||||
4. `AC-K101-04`: Disposable standalone, federated/Swarm, and two-gateway verified-TLS positives plus for both pairs missing CA/wrong CA/wrong SAN/sslmode downgrade, server/Gateway key mode, UID/GID, secret-consumer isolation, and legacy-drain/`hostssl` negatives prove server bootstrap, ordering, and readiness; PGlite is expressly excluded from this PostgreSQL evidence.
|
||||
5. `AC-K101-05`: Real runtime-role evidence proves INSERT/SELECT succeeds and UPDATE/DELETE fails for every frozen immutable KBN relation.
|
||||
6. `AC-K101-06`: N-1/atomic activation/rollback, Vault/CA-overlap rotation/redaction, health/operator behavior, CI/deployment handoff, independent exact-head security review, and terminal-green CI evidence the foundation before KBN-100; after KBN-100, the real deployed-role immutable-operation certificate and Ultron approval release KBN-105.
|
||||
|
||||
**Normative implementation contract:** [`docs/native-kanban-sot/KBN-101-DB-ROLE-SPLIT.md`](./native-kanban-sot/KBN-101-DB-ROLE-SPLIT.md). `ASSUMPTION:` existing `standalone` and `federated` are all PostgreSQL production-like modes; any new PostgreSQL tier inherits these requirements until an explicit versioned amendment.
|
||||
|
||||
---
|
||||
|
||||
## Tess Interaction Agent Workstream (TESS)
|
||||
|
||||
### Problem and Objective
|
||||
@@ -313,37 +284,6 @@ Use TDD for remote-ingress routing and permission boundaries. Required evidence
|
||||
|
||||
---
|
||||
|
||||
## Mos Runtime Portability Workstream (MOS-PORT)
|
||||
|
||||
### Problem and Objective
|
||||
|
||||
Mos is currently identified partly by a harness-native session and communication process. Replacement/rebinding exists, but no gateway-enforced logical identity or fencing prevents a stale harness from continuing to reply or execute effects after takeover.
|
||||
|
||||
The objective is to make Mos a server-derived logical Mosaic identity whose authority can move safely among runtime connectors. The gateway owns identity, lease, policy, and audit; harnesses remain replaceable adapters.
|
||||
|
||||
### M1 Requirements
|
||||
|
||||
1. `MOS-PORT-ID-001`: Define a normalized logical-agent identity independent of Claude Code, Pi, Codex, tmux, Matrix, and provider-native session IDs.
|
||||
2. `MOS-PORT-LEASE-001`: Persist one exclusive connector lease per tenant/logical-agent/binding with CAS acquisition, monotonic fencing epoch, TTL, heartbeat, explicit release, and takeover.
|
||||
3. `MOS-PORT-FENCE-001`: Bind every connector dispatch/execution grant to the current server-derived tenant, logical identity, binding, connector, scopes, expiry, and lease epoch.
|
||||
4. `MOS-PORT-FENCE-002`: Reject and audit stale, expired, forged, cross-tenant, cross-binding, and unauthorized grants before connector, channel, provider, or tool side effects.
|
||||
5. `MOS-PORT-OBS-001`: Emit credential-safe correlation/audit events for lease acquire, renew, takeover, reject, release, and expiry.
|
||||
6. `MOS-PORT-ARCH-001`: Runtime/provider adapters consume normalized lease context without adding harness-native schemas to Mosaic core.
|
||||
|
||||
### M1 Acceptance Criteria
|
||||
|
||||
1. `AC-MOS-PORT-01`: Two contenders for one binding cannot simultaneously hold current authority under concurrency.
|
||||
2. `AC-MOS-PORT-02`: Successful takeover increments the fencing epoch and every operation from the old epoch fails closed before side effects.
|
||||
3. `AC-MOS-PORT-03`: Gateway/database restart preserves lease and epoch state; expired leases can be recovered only through the authorized takeover path.
|
||||
4. `AC-MOS-PORT-04`: Cross-tenant, cross-agent, cross-binding, forged, and expired lease/grant cases are denied and audited.
|
||||
5. `AC-MOS-PORT-05`: Unit, migration, repository close/reopen, concurrency, abuse, gateway integration, independent security review, CI, and documentation gates pass.
|
||||
|
||||
### Deferred to Later #754 Milestones
|
||||
|
||||
Canonical checkpoint/handoff payloads, exactly-once connector receipts, concrete Claude/Pi/Codex adapters, channel cutover, and full cross-harness failover/rollback E2E are explicitly out of M1 scope.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### High-Level System Diagram
|
||||
@@ -1111,10 +1051,10 @@ Telegram remote control channel.
|
||||
|
||||
### AC-10: Deployment
|
||||
|
||||
- [ ] PGlite data-layer work uses no PostgreSQL; optional Compose services are selected individually and do not start PostgreSQL; Gateway/Web local start remains held until KBN-101-02 rejects daemon/inherited/project DSNs before connection or DDL
|
||||
- [ ] PostgreSQL/federated activation is unavailable until KBN-101-00/-03/-05 deliver external bootstrap, TLS/roles, runner `--run`, runner `--verify`, and Gateway/Compose readiness in that order
|
||||
- [ ] `mosaic` CLI installable and functional on bare metal after the reviewed KBN-101-05 secret-renderer/process-exec or `LoadCredential` interface exists
|
||||
- [ ] Local-only configuration documentation is distinct from production generation-pinned Vault-rendered consumer material
|
||||
- [ ] `docker compose up` starts full stack from clean state
|
||||
- [ ] `mosaic` CLI installable and functional on bare metal
|
||||
- [ ] Database migrations run automatically on first start
|
||||
- [ ] `.env.example` documents all required configuration
|
||||
|
||||
### AC-11: @mosaicstack/\* Packages
|
||||
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
# Documentation Sitemap
|
||||
|
||||
## Fleet configuration management
|
||||
|
||||
- [Generated environment boundary](fleet/reference/generated-env-boundary.md) — roster-derived launch projection, strict local data, legacy quarantine, and downstream interface evidence.
|
||||
- [Roster v2 structural contract](fleet/reference/roster-v2-fields.md) — local-tmux schema v2 parsing and structural validation.
|
||||
- [Role classes and authority](fleet/reference/role-classes.md) — canonical role resolver and protected authority boundaries.
|
||||
- [Executable asset dispositions](fleet/migration/example-profile-disposition.md) — shipped v1 fixture/profile/service validation posture.
|
||||
|
||||
## Official channel plugins
|
||||
|
||||
- [Channel protocol architecture](architecture/channel-protocol.md) — shared lifecycle, message, stable-route, authorization, and response-target contracts.
|
||||
@@ -21,10 +14,7 @@
|
||||
- [Workstream index](native-kanban-sot/INDEX.md) — artifact map, lane partition, and delivery order.
|
||||
- [Mission manifest](native-kanban-sot/MISSION-MANIFEST.md) — scope, authority, invariants, and gate model.
|
||||
- [Task decomposition](native-kanban-sot/TASKS.md) — dependency-ordered implementation slices and ownership boundaries.
|
||||
- [KBN-101 database role split](native-kanban-sot/KBN-101-DB-ROLE-SPLIT.md) — rc.16 direct-Drizzle storage-wrapper hold: legacy N-1/uncertified/non-operative pending -02/-03/-06/-08; exact README/user-guide wrapper forms fail before masking and source-consistency rejects runner-delegation copy; held bootstrap → TLS/roles → run → verify → readiness; plus prior attestation, pgvector owner, classifier, TLS, activation, and certification prerequisite.
|
||||
- [Federated tier data migration](guides/migrate-tier.md) — active KBN-101-07 operator route: runner-produced target attestation, dedicated non-DDL importer, and paired credential-/attestation-file references only.
|
||||
- [Frozen shared contract](native-kanban-sot/SHARED-CONTRACT.md) — schema, API, Coordinator, health, recovery, and migration contracts.
|
||||
- [KBN-101 exact-head security review](reports/native-kanban-sot/kbn-101-contract-security-review-82ce325.md) — retained prior REQUEST CHANGES evidence for `da742ca`; rc.16 awaits independent exact-head re-review after closing the current generic storage-wrapper authority HIGH finding.
|
||||
- [Initial independent review](reports/native-kanban-sot/canon-initial-review-no-go.md) — KCR-001–016 findings that blocked the first draft.
|
||||
- [Final independent re-review](reports/native-kanban-sot/canon-final-rereview-go.md) — closure evidence and GO verdict.
|
||||
- [Ultron final gate](reports/native-kanban-sot/ultron-final-go.md) — final requirements, authority, schema, migration, recovery, and evidence review.
|
||||
@@ -66,5 +56,3 @@
|
||||
- [Optional AI egress gateway ADR](architecture/ADR-MOS-EGRESS-GATEWAYS.md) — placement and gates for LiteLLM, Bifrost, and purpose-built translation proxies.
|
||||
- [Runtime-neutral Mos identity and failover mission](https://git.mosaicstack.dev/mosaicstack/stack/issues/754)
|
||||
- [Logical identity and connector lease/fencing implementation](https://git.mosaicstack.dev/mosaicstack/stack/issues/755)
|
||||
- [M1 logical identity and fencing architecture](architecture/mos-runtime-portability-m1.md)
|
||||
- [M1 connector lease operations](guides/mos-connector-lease-operations.md)
|
||||
|
||||
@@ -52,20 +52,20 @@ Active workstream is **W1 — Federation v1**. Workers should:
|
||||
> the repository quality gates, independent code and security review, terminal-green CI, and
|
||||
> the applicable acceptance evidence before merge. Issue #758 remains open until M5 closes.
|
||||
|
||||
| id | status | description | issue | agent | repo | branch | depends_on | estimate | notes |
|
||||
| ---------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ------------- | ----------------- | --------------------------------------- | ---------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------- |
|
||||
| FCM-M0-001 | done | Publish normative PRD requirements/acceptance criteria, this M0–M5 DAG, docs-IA checklist, and legacy example/profile disposition inventory; no implementation changes | #758 | sonnet | mosaicstack/stack | `docs/758-fleet-config-management` | — | 18K | Merged via #760 (`c32d85a`); parent #758 intentionally remains open through M5 |
|
||||
| FCM-M1-001 | done | Implement narrow local-tmux v2 roster structural contract/compiler with YAML/JSON canonicalization and schema/parser parity tests | #758 | coder0 | mosaicstack/stack | `feat/758-roster-v2-compiler` | FCM-M0-001 | 30K | #764 squash `aa5b43b`; exact-head RoR and PR/main terminal-green CI; no lifecycle or live mutation |
|
||||
| FCM-M1-002 | done | Reuse existing profile/persona/provision resolver for roster semantics; add canonical class/authority validation and approved aliases | #758 | native-sonnet | mosaicstack/stack | `feat/758-shared-role-resolution` | FCM-M0-001 | 25K | #768 squash `a5e8e55`; shared resolver and canonical authority/alias validation delivered |
|
||||
| FCM-M1-003 | done | Convert the M0 legacy inventory into executable example/profile/service-preset validation and explicit v1-version/retirement checks | #758 | codex | mosaicstack/stack | `test/758-example-profile-dispositions` | FCM-M1-001, FCM-M1-002 | 20K | #770 squash `e9c4aa3`; shipped artifact disposition validation delivered |
|
||||
| FCM-M2-001 | done | Migrate generic launch chain to deterministic `.env.generated` plus strict data-only `.env.local`; quarantine forbidden legacy keys | #758 | codex | mosaicstack/stack | `feat/758-generated-env-boundary` | FCM-M1-001, FCM-M1-002 | 30K | #772 squash `191efae`; generated/local boundary and private quarantine delivered |
|
||||
| FCM-M2-002 | done | Add generation-guarded local fleet agent create/get/update/delete mutations with plan/dry-run, atomic roster writes, and recovery output | #758 | codex | mosaicstack/stack | `feat/758-fleet-agent-crud` | FCM-M1-001, FCM-M2-001 | 30K | #773 squash `bc5e736`; generation-guarded atomic CRUD and recovery contracts delivered |
|
||||
| FCM-M3-001 | done | Implement local roster-owned reconcile/apply plus lifecycle/status/verify/doctor contracts and stable JSON/exit codes | #758 | codex | mosaicstack/stack | `feat/758-local-reconciler` | FCM-M2-001, FCM-M2-002 | 35K | #785 squash `4990905`; exact roster-owned systemd/tmux reconcile and lifecycle contracts delivered |
|
||||
| FCM-M3-002 | in-progress | Add isolated systemd/tmux lifecycle, drift, socket, unmanaged-session, crash, and rollback acceptance coverage | #758 | sonnet | mosaicstack/stack | `test/758-reconciler-lifecycle-gates` | FCM-M3-001 | 25K | Canonical v2 named-socket + legacy-v1 default-server boundaries; fake adapters/temp fixtures only |
|
||||
| FCM-M4-001 | not-started | Implement field-complete v1-to-v2 inventory/preview/migrator with alias, lifecycle, env-quarantine, and remote/connector disposition evidence | #758 | codex | mosaicstack/stack | `feat/758-v1-v2-migrator` | FCM-M1-003, FCM-M3-001 | 35K | Preview first; no unreviewed lifecycle inference |
|
||||
| FCM-M4-002 | not-started | Add reversible canary migration, rollback, stale-projection/orphan classification, and current-host 9-managed/3-unmanaged fixture coverage | #758 | sonnet | mosaicstack/stack | `test/758-migration-rollback-gates` | FCM-M4-001, FCM-M3-002 | 25K | Never starts a previously stopped agent or kills an unproven unmanaged session |
|
||||
| FCM-M5-001 | not-started | Deliver the accepted fleet documentation IA, how-to/operations/migration references, and link/example validation | #758 | haiku | mosaicstack/stack | `docs/758-fleet-config-operator-docs` | FCM-M1-003, FCM-M2-002, FCM-M3-001, FCM-M4-001 | 24K | Must close every checklist item or record an approved deferral |
|
||||
| FCM-M5-002 | not-started | Package/update asset-drift checks, rolling local canary, independent validation certificate, and release evidence | #758 | sonnet | mosaicstack/stack | `feat/758-fleet-config-release-gate` | FCM-M3-002, FCM-M4-002, FCM-M5-001 | 30K | Final #758 gate: quality, independent code/security review, validator certificate, merge-gate approval, green CI |
|
||||
| id | status | description | issue | agent | repo | branch | depends_on | estimate | notes |
|
||||
| ---------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ------ | ----------------- | --------------------------------------- | ---------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------- |
|
||||
| FCM-M0-001 | in-progress | Publish normative PRD requirements/acceptance criteria, this M0–M5 DAG, docs-IA checklist, and legacy example/profile disposition inventory; no implementation changes | #758 | sonnet | mosaicstack/stack | `docs/758-fleet-config-management` | — | 18K | M0 exit: approved docs; every shipped example/profile/service preset classified; docs-only PR |
|
||||
| FCM-M1-001 | not-started | Implement narrow local-tmux v2 roster structural contract/compiler with YAML/JSON canonicalization and schema/parser parity tests | #758 | codex | mosaicstack/stack | `feat/758-roster-v2-compiler` | FCM-M0-001 | 30K | No lifecycle, remote, connector, secret, channel, or gateway work |
|
||||
| FCM-M1-002 | not-started | Reuse existing profile/persona/provision resolver for roster semantics; add canonical class/authority validation and approved aliases | #758 | codex | mosaicstack/stack | `feat/758-shared-role-resolution` | FCM-M0-001 | 25K | Validator is certificate-only; merge-gate remains sole merge authority |
|
||||
| FCM-M1-003 | not-started | Convert the M0 legacy inventory into executable example/profile/service-preset validation and explicit v1-version/retirement checks | #758 | codex | mosaicstack/stack | `test/758-example-profile-dispositions` | FCM-M1-001, FCM-M1-002 | 20K | Every shipped artifact must validate, be versioned v1, or be retired with replacement |
|
||||
| FCM-M2-001 | not-started | Migrate generic launch chain to deterministic `.env.generated` plus strict data-only `.env.local`; quarantine forbidden legacy keys | #758 | codex | mosaicstack/stack | `feat/758-generated-env-boundary` | FCM-M1-001, FCM-M1-002 | 30K | No arbitrary command compatibility path; diagnostics expose key names/hashes only |
|
||||
| FCM-M2-002 | not-started | Add generation-guarded local fleet agent create/get/update/delete mutations with plan/dry-run, atomic roster writes, and recovery output | #758 | codex | mosaicstack/stack | `feat/758-fleet-agent-crud` | FCM-M1-001, FCM-M2-001 | 30K | Fresh create persists stopped unless explicit persisted start |
|
||||
| FCM-M3-001 | not-started | Implement local roster-owned reconcile/apply plus lifecycle/status/verify/doctor contracts and stable JSON/exit codes | #758 | codex | mosaicstack/stack | `feat/758-local-reconciler` | FCM-M2-001, FCM-M2-002 | 35K | Exact systemd/tmux ownership; remote/schema-only entries are inventory only |
|
||||
| FCM-M3-002 | not-started | Add isolated systemd/tmux lifecycle, drift, socket, unmanaged-session, crash, and rollback acceptance coverage | #758 | sonnet | mosaicstack/stack | `test/758-reconciler-lifecycle-gates` | FCM-M3-001 | 25K | Proves stopped-state preservation and zero fuzzy destructive targeting |
|
||||
| FCM-M4-001 | not-started | Implement field-complete v1-to-v2 inventory/preview/migrator with alias, lifecycle, env-quarantine, and remote/connector disposition evidence | #758 | codex | mosaicstack/stack | `feat/758-v1-v2-migrator` | FCM-M1-003, FCM-M3-001 | 35K | Preview first; no unreviewed lifecycle inference |
|
||||
| FCM-M4-002 | not-started | Add reversible canary migration, rollback, stale-projection/orphan classification, and current-host 9-managed/3-unmanaged fixture coverage | #758 | sonnet | mosaicstack/stack | `test/758-migration-rollback-gates` | FCM-M4-001, FCM-M3-002 | 25K | Never starts a previously stopped agent or kills an unproven unmanaged session |
|
||||
| FCM-M5-001 | not-started | Deliver the accepted fleet documentation IA, how-to/operations/migration references, and link/example validation | #758 | haiku | mosaicstack/stack | `docs/758-fleet-config-operator-docs` | FCM-M1-003, FCM-M2-002, FCM-M3-001, FCM-M4-001 | 24K | Must close every checklist item or record an approved deferral |
|
||||
| FCM-M5-002 | not-started | Package/update asset-drift checks, rolling local canary, independent validation certificate, and release evidence | #758 | sonnet | mosaicstack/stack | `feat/758-fleet-config-release-gate` | FCM-M3-002, FCM-M4-002, FCM-M5-001 | 30K | Final #758 gate: quality, independent code/security review, validator certificate, merge-gate approval, green CI |
|
||||
|
||||
## Thin-core prompt diet (#528) — feat/contract-thin-core
|
||||
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
# Mos Runtime Portability M1 — Logical Identity and Fencing
|
||||
|
||||
## Boundary
|
||||
|
||||
M1 separates the logical Mosaic agent from any Claude, Pi, Codex, tmux, Matrix, or provider-native session. The normalized identity is:
|
||||
|
||||
```text
|
||||
(tenant_id, logical_agent_id, binding_id)
|
||||
```
|
||||
|
||||
`logical_agent_id` is a server-owned stable identifier. A connector is a replaceable holder of a lease for one binding; it is not the agent identity.
|
||||
|
||||
## Durable lease model
|
||||
|
||||
PostgreSQL table `logical_agent_connector_leases` has one unique row per identity/binding tuple. The current row records:
|
||||
|
||||
- an opaque lease UUID;
|
||||
- connector ID and normalized allowed scopes;
|
||||
- a positive decimal fencing epoch stored as PostgreSQL `bigint`;
|
||||
- acquired, heartbeat, expiry, release, and update timestamps.
|
||||
|
||||
Initial acquisition is insert-only. An existing active row causes `lease_held`. An expired or released row causes `takeover_required`; ordinary acquisition cannot recover it. Authorized takeover uses compare-and-swap against the expected epoch, rotates the lease UUID, and increments the epoch atomically. Heartbeat and release match the full identity, binding, connector, lease UUID, and epoch.
|
||||
|
||||
The companion `connector_lease_audit_log` is append-only metadata. It stores lifecycle event, outcome/reason, identity/binding/connector, epoch, correlation ID, and timestamp. It deliberately excludes scopes, grant objects, payloads, approval references, tokens, and credentials.
|
||||
|
||||
## Execution grants
|
||||
|
||||
`ConnectorLeaseCoordinator` issues a short-lived internal grant only after rereading the durable current lease. Defense-in-depth caps leases at 5 minutes and grants at 30 seconds by default; constructor options may tighten these limits. A grant is bound to tenant, logical agent, binding, connector, lease UUID, scope subset, expiry, and epoch.
|
||||
|
||||
Validation occurs immediately before adapter invocation and rereads PostgreSQL. The adapter receives only `ConnectorExecutionContext`; harness-native schemas remain behind the adapter. Validation denies:
|
||||
|
||||
- grants not minted by the current gateway process (including cloned/forged objects);
|
||||
- expired grants or leases;
|
||||
- released leases;
|
||||
- stale epochs or replaced connector/lease UUIDs;
|
||||
- missing/cross-tenant/cross-agent/cross-binding leases;
|
||||
- scopes not authorized by both grant and current lease.
|
||||
|
||||
A gateway restart intentionally invalidates process-local grants. The durable lease and epoch survive, and a fresh grant may be issued only after current-lease and gateway-policy validation.
|
||||
|
||||
## Concurrency and side-effect rule
|
||||
|
||||
The database CAS determines the sole current holder. A successful takeover makes every old-epoch validation fail. Connector adapters must consume and propagate the normalized lease epoch/context so downstream effect boundaries can also fence races that occur after gateway validation.
|
||||
|
||||
M1 does not provide exactly-once receipts or a side-effect journal. Those remain later #754 work; callers must not infer exactly-once delivery from lease fencing.
|
||||
|
||||
## Extension boundary
|
||||
|
||||
`ConnectorLeaseService` is the gateway-owned policy surface. Every policy decision receives the normalized requested scopes and TTL (or explicit `null` where no TTL applies), so a concrete policy can enforce least privilege and duration limits. Its production default policy denies every lease/grant operation until a server-configured connector policy is supplied. No M1 HTTP endpoint accepts caller-controlled tenant or logical identity, and no concrete Claude/Pi/Codex adapter or channel cutover is included.
|
||||
@@ -70,10 +70,6 @@ export function createQueue(config?: QueueConfig): QueueHandle {
|
||||
|
||||
### `@mosaicstack/db` (packages/db/src/client.ts)
|
||||
|
||||
> **Historical design specimen — status-only, not an operator instruction.** KBN-101 supersedes
|
||||
> this pre-split `DATABASE_URL` fallback shape; it cannot authorize runtime migration, DDL, or a
|
||||
> connection-string fallback. See the KBN-101 runner/role contract for the produced interface.
|
||||
|
||||
```typescript
|
||||
import { drizzle, type PostgresJsDatabase } from 'drizzle-orm/postgres-js';
|
||||
import postgres from 'postgres';
|
||||
|
||||
@@ -54,7 +54,7 @@ Every milestone adds tests to these layers. A milestone cannot be claimed comple
|
||||
- Add `"tier": "federated"` to `mosaic.config.json` schema and validators
|
||||
- Docker Compose `federated` profile (`docker-compose.federated.yml`) adds: Postgres+pgvector (5433), Valkey (6380), dedicated volumes
|
||||
- Tier detector in gateway bootstrap: reads config, asserts required services reachable, refuses to start otherwise
|
||||
- **Historical/status only:** the prior startup-provisioning statement is superseded. Runtime/startup extension provisioning is forbidden. PostgreSQL activation remains non-operative with no current command authority until KBN-101-00, KBN-101-03, and KBN-101-05 land; this record authorizes no current DDL, Compose/init, or startup path.
|
||||
- `pgvector` extension installed + verified on startup
|
||||
- Migration logic: safe upgrade path from `local`/`standalone` → `federated` (data export/import script, one-way)
|
||||
- `mosaic doctor` reports tier + service health
|
||||
- Gateway continues to serve as a normal standalone instance (no federation yet)
|
||||
|
||||
@@ -1,74 +1,280 @@
|
||||
# Federated Tier Setup Guide
|
||||
|
||||
> **KBN-101 N-1 hold:** This page is **non-operative** and grants no current command
|
||||
> authority until KBN-101-00, KBN-101-03, and KBN-101-05 land and KBN-101-08 activates a
|
||||
> reviewed release. It does not authorize a deployment operation, initialization artifacts,
|
||||
> implicit extension/schema/migration creation, raw `CREATE`, direct database initialization, or
|
||||
> a Gateway against an unverified database. The prior direct-start wording is retired; its
|
||||
> regression fixture is owned by KBN-101-06.
|
||||
## What is the federated tier?
|
||||
|
||||
## Held future procedure
|
||||
The federated tier is designed for multi-user and multi-host deployments. It consists of PostgreSQL 17 with pgvector extension (for embeddings and RAG), Valkey for distributed task queueing and caching, and a shared configuration across multiple Mosaic gateway instances. Use this tier when running Mosaic in production or when scaling beyond a single-host deployment.
|
||||
|
||||
This section is non-operative and grants no current command authority until KBN-101-00, KBN-101-03, and KBN-101-05 land.
|
||||
## Prerequisites
|
||||
|
||||
The deployment control plane—not an operator shell or deployment lifecycle hook—performs this
|
||||
exact held future sequence after activation authorization: external bootstrap → TLS/roles → `mosaic-db-migrator --run` → `mosaic-db-migrator --verify` → Gateway/Compose readiness.
|
||||
- Docker and Docker Compose installed
|
||||
- Ports 5433 (PostgreSQL) and 6380 (Valkey) available on your host (or adjust environment variables)
|
||||
- At least 2 GB free disk space for data volumes
|
||||
|
||||
1. External bootstrap provisions the approved database/extension prerequisites.
|
||||
2. TLS/roles are installed through the generation-pinned renderer.
|
||||
3. The dedicated one-shot runner executes `mosaic-db-migrator --run`.
|
||||
4. The same runner executes `mosaic-db-migrator --verify`, including readiness and the
|
||||
importer-target attestation where that route is enabled.
|
||||
5. Only after successful verification may Gateway reach its independent verified-TLS Gateway
|
||||
readiness gate.
|
||||
## Start the federated stack
|
||||
|
||||
No step may be reordered, skipped, replaced by a raw SQL command, or delegated to an initialization
|
||||
hook.
|
||||
A missing extension, schema, migration, role, secret generation, or readiness proof is a failed
|
||||
control-plane precondition; it is not an instruction to start Compose, retry startup, or create
|
||||
anything directly.
|
||||
Run the federated overlay:
|
||||
|
||||
## N-1 status and required disposition
|
||||
```bash
|
||||
docker compose -f docker-compose.federated.yml --profile federated up -d
|
||||
```
|
||||
|
||||
The current branch retains historical federation artifacts, but they are not a deployable
|
||||
procedure. `docs/federation/TASKS.md` records their shipped status only. KBN-101-02 retires
|
||||
runtime/init DDL; KBN-101-05 owns the renderer/deployment handoff; KBN-101-06 verifies the
|
||||
finite scanner and command matrix; and KBN-101-07 owns this operator route. A path named in an
|
||||
inventory, a historical-status label, or a normative requirement cannot suppress the semantic
|
||||
checks above.
|
||||
This starts PostgreSQL 17 with pgvector and Valkey 8. The pgvector extension is created automatically on first boot.
|
||||
|
||||
Until the activation certificate names an exact release, use no database startup or recovery
|
||||
command from this document. For the produced importer interface, see
|
||||
[the federated tier migration contract](../guides/migrate-tier.md); it is likewise non-operative
|
||||
until activation.
|
||||
Verify the services are running:
|
||||
|
||||
## Federation and Step-CA reference
|
||||
```bash
|
||||
docker compose -f docker-compose.federated.yml ps
|
||||
```
|
||||
|
||||
Federation uses PostgreSQL 17 with pgvector, Valkey, and a shared configuration across multiple
|
||||
Gateway instances. Step-CA issues federation peer X.509 certificates whose custom OIDs carry a
|
||||
grant and subject identity. The following facts are reference material only; provisioning and
|
||||
secret delivery remain deployment-control-plane work under the activation sequence.
|
||||
Expected output shows `postgres-federated` and `valkey-federated` both healthy.
|
||||
|
||||
| OID | Name | Description |
|
||||
| ------------------- | ------------------------ | --------------------- |
|
||||
| 1.3.6.1.4.1.99999.1 | `mosaic_grant_id` | Federation grant UUID |
|
||||
| 1.3.6.1.4.1.99999.2 | `mosaic_subject_user_id` | Subject user UUID |
|
||||
## Configure mosaic for federated tier
|
||||
|
||||
The internal arc `1.3.6.1.4.1.99999` is development-only. Before an externally reachable
|
||||
production deployment, register an IANA Private Enterprise Number and version the assignments.
|
||||
Each value is DER-encoded as an ASN.1 UTF8String containing the UUID.
|
||||
Create or update your `mosaic.config.json`:
|
||||
|
||||
The future activated Gateway requires `STEP_CA_URL`, `STEP_CA_PROVISIONER_PASSWORD`,
|
||||
`STEP_CA_PROVISIONER_KEY_JSON`, `STEP_CA_ROOT_CERT_PATH`, and `BETTER_AUTH_SECRET` through the
|
||||
reviewed secret mechanism. These names do not authorize shell exports, copied credential files,
|
||||
or an ad hoc service start.
|
||||
```json
|
||||
{
|
||||
"tier": "federated",
|
||||
"database": "postgresql://mosaic:mosaic@localhost:5433/mosaic",
|
||||
"queue": "redis://localhost:6380"
|
||||
}
|
||||
```
|
||||
|
||||
## Failure disposition
|
||||
If you're using environment variables instead:
|
||||
|
||||
- A TLS, CA, SAN, role, runner, or readiness failure is a control-plane incident. Preserve only
|
||||
sanitized evidence and follow the approved rollback/repair record.
|
||||
- A pgvector/extension failure is a failed external-bootstrap or runner precondition. Do not use
|
||||
direct extension SQL, init artifacts, or a startup retry as remediation.
|
||||
- A port, container, or Valkey problem does not permit bypassing the activation sequence.
|
||||
- Federation peer-key rotation remains deferred until its separately approved migration plan;
|
||||
do not rotate `BETTER_AUTH_SECRET` without that plan.
|
||||
```bash
|
||||
export DATABASE_URL="postgresql://mosaic:mosaic@localhost:5433/mosaic"
|
||||
export REDIS_URL="redis://localhost:6380"
|
||||
```
|
||||
|
||||
## Verify health
|
||||
|
||||
Run the health check:
|
||||
|
||||
```bash
|
||||
mosaic gateway doctor
|
||||
```
|
||||
|
||||
Expected output (green):
|
||||
|
||||
```
|
||||
Tier: federated Config: mosaic.config.json
|
||||
✓ postgres localhost:5433 (42ms)
|
||||
✓ valkey localhost:6380 (8ms)
|
||||
✓ pgvector (embedded) (15ms)
|
||||
```
|
||||
|
||||
For JSON output (useful in CI/automation):
|
||||
|
||||
```bash
|
||||
mosaic gateway doctor --json
|
||||
```
|
||||
|
||||
## Step 2: Step-CA Bootstrap
|
||||
|
||||
Step-CA is a certificate authority that issues X.509 certificates for federation peers. In Mosaic federation, it signs peer certificates with custom OIDs that embed grant and user identities, enforcing authorization at the certificate level.
|
||||
|
||||
### Prerequisites for Step-CA
|
||||
|
||||
Before starting the CA, you must set up the dev password:
|
||||
|
||||
```bash
|
||||
cp infra/step-ca/dev-password.example infra/step-ca/dev-password
|
||||
# Edit dev-password and set your CA password (minimum 16 characters)
|
||||
```
|
||||
|
||||
The password is required for the CA to boot and derive the provisioner key used by the gateway.
|
||||
|
||||
### Start the Step-CA service
|
||||
|
||||
Add the step-ca service to your federated stack:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.federated.yml --profile federated up -d step-ca
|
||||
```
|
||||
|
||||
On first boot, the init script (`infra/step-ca/init.sh`) runs automatically. It:
|
||||
|
||||
- Generates the CA root key and certificate in the Docker volume
|
||||
- Creates the `mosaic-fed` JWK provisioner
|
||||
- Applies the X.509 template from `infra/step-ca/templates/federation.tpl`
|
||||
|
||||
The volume is persistent, so subsequent boots reuse the existing CA keys.
|
||||
|
||||
Verify the CA is healthy:
|
||||
|
||||
```bash
|
||||
curl https://localhost:9000/health --cacert /tmp/step-ca-root.crt
|
||||
```
|
||||
|
||||
(If the root cert file doesn't exist yet, see the extraction steps below.)
|
||||
|
||||
### Extract credentials for the gateway
|
||||
|
||||
The gateway requires two credentials from the running CA:
|
||||
|
||||
**1. Provisioner key (for `STEP_CA_PROVISIONER_KEY_JSON`)**
|
||||
|
||||
```bash
|
||||
docker exec $(docker ps -qf name=step-ca) cat /home/step/secrets/mosaic-fed.json > /tmp/step-ca-provisioner.json
|
||||
```
|
||||
|
||||
This JSON file contains the JWK public and private keys for the `mosaic-fed` provisioner. Store it securely and pass its contents to the gateway via the `STEP_CA_PROVISIONER_KEY_JSON` environment variable.
|
||||
|
||||
**2. Root certificate (for `STEP_CA_ROOT_CERT_PATH`)**
|
||||
|
||||
```bash
|
||||
docker cp $(docker ps -qf name=step-ca):/home/step/certs/root_ca.crt /tmp/step-ca-root.crt
|
||||
```
|
||||
|
||||
This PEM file is the CA's root certificate, used to verify peer certificates issued by step-ca. Pass its path to the gateway via `STEP_CA_ROOT_CERT_PATH`.
|
||||
|
||||
### Custom OID Registry
|
||||
|
||||
Federation certificates include custom OIDs in the certificate extension. These encode authorization metadata:
|
||||
|
||||
| OID | Name | Description |
|
||||
| ------------------- | ---------------------- | --------------------- |
|
||||
| 1.3.6.1.4.1.99999.1 | mosaic_grant_id | Federation grant UUID |
|
||||
| 1.3.6.1.4.1.99999.2 | mosaic_subject_user_id | Subject user UUID |
|
||||
|
||||
These OIDs are verified by the gateway after the CSR is signed, ensuring the certificate was issued with the correct grant and user context.
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Configure the gateway with the following environment variables before startup:
|
||||
|
||||
| Variable | Required | Description |
|
||||
| ------------------------------ | -------- | --------------------------------------------------------------------------------------------------------- |
|
||||
| `STEP_CA_URL` | Yes | Base URL of the step-ca instance, e.g. `https://step-ca:9000` (use `https://localhost:9000` in local dev) |
|
||||
| `STEP_CA_PROVISIONER_KEY_JSON` | Yes | JSON-encoded JWK from `/home/step/secrets/mosaic-fed.json` |
|
||||
| `STEP_CA_ROOT_CERT_PATH` | Yes | Absolute path to the root CA certificate (e.g. `/tmp/step-ca-root.crt`) |
|
||||
| `BETTER_AUTH_SECRET` | Yes | Secret used to seal peer private keys at rest; already required for M1 |
|
||||
|
||||
Example environment setup:
|
||||
|
||||
```bash
|
||||
export STEP_CA_URL="https://localhost:9000"
|
||||
export STEP_CA_PROVISIONER_KEY_JSON="$(cat /tmp/step-ca-provisioner.json)"
|
||||
export STEP_CA_ROOT_CERT_PATH="/tmp/step-ca-root.crt"
|
||||
export BETTER_AUTH_SECRET="<your-secret>"
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Port conflicts
|
||||
|
||||
**Symptom:** `bind: address already in use`
|
||||
|
||||
**Fix:** Stop the base dev stack first:
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
docker compose -f docker-compose.federated.yml --profile federated up -d
|
||||
```
|
||||
|
||||
Or change the host port with an environment variable:
|
||||
|
||||
```bash
|
||||
PG_FEDERATED_HOST_PORT=5434 VALKEY_FEDERATED_HOST_PORT=6381 \
|
||||
docker compose -f docker-compose.federated.yml --profile federated up -d
|
||||
```
|
||||
|
||||
### pgvector extension error
|
||||
|
||||
**Symptom:** `ERROR: could not open extension control file`
|
||||
|
||||
**Fix:** pgvector is created at first boot. Check logs:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.federated.yml logs postgres-federated | grep -i vector
|
||||
```
|
||||
|
||||
If missing, exec into the container and create it manually:
|
||||
|
||||
```bash
|
||||
docker exec <postgres-federated-id> psql -U mosaic -d mosaic -c "CREATE EXTENSION vector;"
|
||||
```
|
||||
|
||||
### Valkey connection refused
|
||||
|
||||
**Symptom:** `Error: connect ECONNREFUSED 127.0.0.1:6380`
|
||||
|
||||
**Fix:** Check service health:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.federated.yml logs valkey-federated
|
||||
```
|
||||
|
||||
If Valkey is running, verify your firewall allows 6380. On macOS, Docker Desktop may require binding to `host.docker.internal` instead of `localhost`.
|
||||
|
||||
## Key rotation (deferred)
|
||||
|
||||
Federation peer private keys (`federation_peers.client_key_pem`) are sealed at rest using AES-256-GCM with a key derived from `BETTER_AUTH_SECRET` via SHA-256. If `BETTER_AUTH_SECRET` is rotated, all sealed `client_key_pem` values in the database become unreadable and must be re-sealed with the new key before rotation completes.
|
||||
|
||||
The full key rotation procedure (decrypt all rows with old key, re-encrypt with new key, atomically swap the secret) is out of scope for M2. Operators must not rotate `BETTER_AUTH_SECRET` without a migration plan for all sealed federation peer keys.
|
||||
|
||||
## OID Assignments — Mosaic Internal OID Arc
|
||||
|
||||
Mosaic uses the private enterprise arc `1.3.6.1.4.1.99999` for custom X.509
|
||||
certificate extensions in federation grant certificates.
|
||||
|
||||
**IMPORTANT:** This is a development/internal OID arc. Before deploying to a
|
||||
production environment accessible by external parties, register a proper IANA
|
||||
Private Enterprise Number (PEN) at <https://pen.iana.org/pen/PenApplication.page>
|
||||
and update these assignments accordingly.
|
||||
|
||||
### Assigned OIDs
|
||||
|
||||
| OID | Symbolic name | Description |
|
||||
| --------------------- | --------------------------------- | --------------------------------------------------------- |
|
||||
| `1.3.6.1.4.1.99999.1` | `mosaic.federation.grantId` | UUID of the `federation_grants` row authorising this cert |
|
||||
| `1.3.6.1.4.1.99999.2` | `mosaic.federation.subjectUserId` | UUID of the local user on whose behalf the cert is issued |
|
||||
|
||||
### Encoding
|
||||
|
||||
Each extension value is DER-encoded as an ASN.1 **UTF8String**:
|
||||
|
||||
```
|
||||
Tag 0x0C (UTF8String)
|
||||
Length 0x24 (36 decimal — fixed length of a UUID string)
|
||||
Value <36 ASCII bytes of the UUID>
|
||||
```
|
||||
|
||||
The step-ca X.509 template at `infra/step-ca/templates/federation.tpl`
|
||||
produces this encoding via the Go template expression:
|
||||
|
||||
```
|
||||
{{ printf "\x0c\x24%s" .Token.mosaic_grant_id | b64enc }}
|
||||
```
|
||||
|
||||
The resulting base64 value is passed as the `value` field of the extension
|
||||
object in the template JSON.
|
||||
|
||||
### CA Environment Variables
|
||||
|
||||
The `CaService` (`apps/gateway/src/federation/ca.service.ts`) requires the
|
||||
following environment variables at gateway startup:
|
||||
|
||||
| Variable | Required | Description |
|
||||
| ------------------------------ | -------- | -------------------------------------------------------------------- |
|
||||
| `STEP_CA_URL` | Yes | Base URL of the step-ca instance, e.g. `https://step-ca:9000` |
|
||||
| `STEP_CA_PROVISIONER_PASSWORD` | Yes | JWK provisioner password for the `mosaic-fed` provisioner |
|
||||
| `STEP_CA_PROVISIONER_KEY_JSON` | Yes | JSON-encoded JWK (public + private) for the `mosaic-fed` provisioner |
|
||||
| `STEP_CA_ROOT_CERT_PATH` | Yes | Absolute path to the step-ca root CA certificate PEM file |
|
||||
|
||||
Set these variables in your environment or secret manager before starting
|
||||
the gateway. In the federated Docker Compose stack they are expected to be
|
||||
injected via Docker secrets and environment variable overrides.
|
||||
|
||||
### Fail-loud contract
|
||||
|
||||
The CA service (and the X.509 template) are designed to fail loudly if the
|
||||
custom OIDs cannot be embedded:
|
||||
|
||||
- The template produces a malformed extension value (zero-length UTF8String
|
||||
body) when the JWT claims `mosaic_grant_id` or `mosaic_subject_user_id` are
|
||||
absent. step-ca rejects the CSR rather than issuing a cert without the OIDs.
|
||||
- `CaService.issueCert()` throws a `CaServiceError` on every error path with
|
||||
a human-readable `remediation` string. It never silently returns a cert that
|
||||
may be missing the required extensions.
|
||||
|
||||
@@ -15,20 +15,20 @@
|
||||
|
||||
Goal: Gateway runs in `federated` tier with containerized PG+pgvector+Valkey. No federation logic yet. Existing standalone behavior does not regress.
|
||||
|
||||
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||
| --------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----- | ------ | ---------------------------------- | ---------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| FED-M1-01 | done | Extend `mosaic.config.json` schema: add `"federated"` to `tier` enum in validator + TS types. Keep `local` and `standalone` working. Update schema docs/README where referenced. | #460 | sonnet | feat/federation-m1-tier-config | — | 4K | Shipped in PR #470. Renamed `team` → `standalone`; added `team` deprecation alias; added `DEFAULT_FEDERATED_CONFIG`. |
|
||||
| FED-M1-02 | done | Historical shipped-status record: authored a federated Compose overlay with PostgreSQL/pgvector, Valkey, volumes, and healthchecks. It is not a current startup, init, extension, schema, or migration procedure. | #460 | sonnet | feat/federation-m1-compose | FED-M1-01 | 5K | Shipped in PR #471 status only. KBN-101-02 retires its init authority; KBN-101-05 replaces deployment rendering; KBN-101-07 SETUP is non-operative until activation. |
|
||||
| FED-M1-03 | done | Historical shipped-status record: add pgvector support to `packages/storage/src/adapters/postgres.ts`; no adapter changes for non-federated tiers. | #460 | sonnet | feat/federation-m1-pgvector | FED-M1-02 | 8K | Shipped in PR #472 status only. **KBN-101 supersedes this behavior:** it cannot authorize current runtime extension creation or any DDL; only the runner/external bootstrap contract may do so. |
|
||||
| FED-M1-04 | done | Implement `apps/gateway/src/bootstrap/tier-detector.ts`: reads config, asserts PG/Valkey/pgvector reachable for `federated`, fail-fast with actionable error message on failure. Unit tests for each failure mode. | #460 | sonnet | feat/federation-m1-detector | FED-M1-03 | 8K | Shipped in PR #473. 12 tests; 5s timeouts on probes; pgvector library/permission discrimination; rejects non-bullmq for federated. |
|
||||
| FED-M1-05 | done | Historical shipped-status record: prior tier migration implementation. | #460 | sonnet | feat/federation-m1-migrate | FED-M1-04 | 10K | Shipped in PR #474 status only. **KBN-101 supersedes this route:** it cannot authorize current credentials, target connection, or DDL. The future active route requires runner verification plus target URL-file and signed attestation-file binding. |
|
||||
| FED-M1-06 | done | Update `mosaic doctor`: report current tier, required services, actual health per service, pgvector presence, overall green/yellow/red. Machine-readable JSON output flag for CI use. | #460 | sonnet | feat/federation-m1-doctor | FED-M1-04 | 6K | Shipped in PR #475 as `mosaic gateway doctor`. Probes lifted to @mosaicstack/storage; structural TierConfig breaks dep cycle. |
|
||||
| FED-M1-07 | done | Integration test: gateway boots in `federated` tier with docker-compose `federated` profile; refuses to boot when PG unreachable (asserts fail-fast); pgvector extension query succeeds. | #460 | sonnet | feat/federation-m1-integration | FED-M1-04 | 8K | Shipped in PR #476. 3 test files, 4 tests, gated by FEDERATED_INTEGRATION=1; reserved-port helper avoids host collisions. |
|
||||
| FED-M1-08 | done | Integration test for migration script: seed a local PGlite with representative data (tasks, notes, users, teams), run migration, assert row counts + key samples equal on federated PG. | #460 | sonnet | feat/federation-m1-migrate-test | FED-M1-05 | 6K | Shipped in PR #477. Caught P0 in M1-05 (camelCase→snake_case) missed by mocked unit tests; fix in same PR. |
|
||||
| FED-M1-09 | done | Standalone regression: full agent-session E2E on existing `standalone` tier with a gateway built from this branch. Must pass without referencing any federation module. | #460 | sonnet | feat/federation-m1-regression | FED-M1-07 | 4K | Clean canary. 351 gateway tests + 85 storage unit tests + full pnpm test all green; only FEDERATED_INTEGRATION-gated tests skip. |
|
||||
| FED-M1-10 | done | Code review pass: security-focused on the migration script (data-at-rest during migration) + tier detector (error-message sensitivity leakage). Independent reviewer, not authors of tasks 01-09. | #460 | sonnet | feat/federation-m1-security-review | FED-M1-09 | 8K | 2 review rounds caught 7 issues: credential leak in pg/valkey/pgvector errors + redact-error util; missing advisory lock; SKIP_TABLES rationale. |
|
||||
| FED-M1-11 | done | Docs update: `docs/federation/` operator notes for tier setup; README blurb on federated tier; `docs/guides/` entry for migration. Do NOT touch runbook yet (deferred to FED-M7). | #460 | haiku | feat/federation-m1-docs | FED-M1-10 | 4K | Shipped: `docs/federation/SETUP.md` (119 lines), `docs/guides/migrate-tier.md` (147 lines), README Configuration blurb. |
|
||||
| FED-M1-12 | done | PR, CI green, merge to main, close #460. | #460 | sonnet | feat/federation-m1-close | FED-M1-11 | 3K | M1 closed. PRs #470-#480 merged across 11 tasks. Issue #460 closed; release tag `fed-v0.1.0-m1` published. |
|
||||
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||
| --------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----- | ------ | ---------------------------------- | ---------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| FED-M1-01 | done | Extend `mosaic.config.json` schema: add `"federated"` to `tier` enum in validator + TS types. Keep `local` and `standalone` working. Update schema docs/README where referenced. | #460 | sonnet | feat/federation-m1-tier-config | — | 4K | Shipped in PR #470. Renamed `team` → `standalone`; added `team` deprecation alias; added `DEFAULT_FEDERATED_CONFIG`. |
|
||||
| FED-M1-02 | done | Author `docker-compose.federated.yml` as an overlay profile: Postgres 17 + pgvector extension (port 5433), Valkey (6380), named volumes, healthchecks. Compose-up should boot cleanly on a clean machine. | #460 | sonnet | feat/federation-m1-compose | FED-M1-01 | 5K | Shipped in PR #471. Overlay defines `postgres-federated`/`valkey-federated`, profile-gated, with pg-init for pgvector extension. |
|
||||
| FED-M1-03 | done | Add pgvector support to `packages/storage/src/adapters/postgres.ts`: create extension on init (idempotent), expose vector column type in schema helpers. No adapter changes for non-federated tiers. | #460 | sonnet | feat/federation-m1-pgvector | FED-M1-02 | 8K | Shipped in PR #472. `enableVector` flag on postgres StorageConfig; idempotent CREATE EXTENSION before migrations. |
|
||||
| FED-M1-04 | done | Implement `apps/gateway/src/bootstrap/tier-detector.ts`: reads config, asserts PG/Valkey/pgvector reachable for `federated`, fail-fast with actionable error message on failure. Unit tests for each failure mode. | #460 | sonnet | feat/federation-m1-detector | FED-M1-03 | 8K | Shipped in PR #473. 12 tests; 5s timeouts on probes; pgvector library/permission discrimination; rejects non-bullmq for federated. |
|
||||
| FED-M1-05 | done | Write `scripts/migrate-to-federated.ts`: one-way migration from `local` (PGlite) / `standalone` (PG without pgvector) → `federated`. Dumps, transforms, loads; dry-run + confirm UX. Idempotent on re-run. | #460 | sonnet | feat/federation-m1-migrate | FED-M1-04 | 10K | Shipped in PR #474. `mosaic storage migrate-tier`; DrizzleMigrationSource (corrects P0 found in review); 32 tests; idempotent. |
|
||||
| FED-M1-06 | done | Update `mosaic doctor`: report current tier, required services, actual health per service, pgvector presence, overall green/yellow/red. Machine-readable JSON output flag for CI use. | #460 | sonnet | feat/federation-m1-doctor | FED-M1-04 | 6K | Shipped in PR #475 as `mosaic gateway doctor`. Probes lifted to @mosaicstack/storage; structural TierConfig breaks dep cycle. |
|
||||
| FED-M1-07 | done | Integration test: gateway boots in `federated` tier with docker-compose `federated` profile; refuses to boot when PG unreachable (asserts fail-fast); pgvector extension query succeeds. | #460 | sonnet | feat/federation-m1-integration | FED-M1-04 | 8K | Shipped in PR #476. 3 test files, 4 tests, gated by FEDERATED_INTEGRATION=1; reserved-port helper avoids host collisions. |
|
||||
| FED-M1-08 | done | Integration test for migration script: seed a local PGlite with representative data (tasks, notes, users, teams), run migration, assert row counts + key samples equal on federated PG. | #460 | sonnet | feat/federation-m1-migrate-test | FED-M1-05 | 6K | Shipped in PR #477. Caught P0 in M1-05 (camelCase→snake_case) missed by mocked unit tests; fix in same PR. |
|
||||
| FED-M1-09 | done | Standalone regression: full agent-session E2E on existing `standalone` tier with a gateway built from this branch. Must pass without referencing any federation module. | #460 | sonnet | feat/federation-m1-regression | FED-M1-07 | 4K | Clean canary. 351 gateway tests + 85 storage unit tests + full pnpm test all green; only FEDERATED_INTEGRATION-gated tests skip. |
|
||||
| FED-M1-10 | done | Code review pass: security-focused on the migration script (data-at-rest during migration) + tier detector (error-message sensitivity leakage). Independent reviewer, not authors of tasks 01-09. | #460 | sonnet | feat/federation-m1-security-review | FED-M1-09 | 8K | 2 review rounds caught 7 issues: credential leak in pg/valkey/pgvector errors + redact-error util; missing advisory lock; SKIP_TABLES rationale. |
|
||||
| FED-M1-11 | done | Docs update: `docs/federation/` operator notes for tier setup; README blurb on federated tier; `docs/guides/` entry for migration. Do NOT touch runbook yet (deferred to FED-M7). | #460 | haiku | feat/federation-m1-docs | FED-M1-10 | 4K | Shipped: `docs/federation/SETUP.md` (119 lines), `docs/guides/migrate-tier.md` (147 lines), README Configuration blurb. |
|
||||
| FED-M1-12 | done | PR, CI green, merge to main, close #460. | #460 | sonnet | feat/federation-m1-close | FED-M1-11 | 3K | M1 closed. PRs #470-#480 merged across 11 tasks. Issue #460 closed; release tag `fed-v0.1.0-m1` published. |
|
||||
|
||||
**M1 total estimate:** ~74K tokens (over-budget vs 20K PRD estimate — explanation below)
|
||||
|
||||
|
||||
@@ -1,77 +1,114 @@
|
||||
# Fleet Launch Runbook
|
||||
|
||||
The local fleet roster is the sole writable desired-state authority for membership and launch policy.
|
||||
Generated environment files are rebuildable projections, not an operator-editable command surface.
|
||||
How every Mosaic fleet agent — workers **and** the orchestrator — is launched, and how to
|
||||
configure each one. The guiding principle: **one roster-driven launcher**. There is no bespoke
|
||||
per-agent launch script; the roster plus per-agent `.env` files are the single source of launch
|
||||
config.
|
||||
|
||||
## Launch chain
|
||||
## The launch chain
|
||||
|
||||
| Layer | Responsibility |
|
||||
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Roster | `fleet/roster.yaml` supplies the agent name, class, supported runtime, model, reasoning, tool policy, workdir, and tmux socket. |
|
||||
| Projection writer | Renders deterministic `fleet/agents/<name>.env.generated` from the roster. |
|
||||
| Optional local data | Reads a strict, data-only `fleet/agents/<name>.env.local`; it cannot shadow generated keys. |
|
||||
| systemd | Starts the launcher with `env -i` and fixed bootstrap data. It does not preload either environment file. |
|
||||
| session launcher | Validates generated and local data before it queries, creates, or stops an exact tmux session. |
|
||||
| runtime launch | Derives the fixed `mosaic yolo <runtime>` argument array from validated roster data, then seeds the runtime contract. |
|
||||
| Layer | File | Responsibility |
|
||||
| ---------------- | ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| systemd unit | `mosaic-agent@<role>.service` | One templated unit per role; `ExecStart` runs the session launcher with the instance name `%i`. Defaults `MOSAIC_AGENT_RUNTIME=pi`, `MOSAIC_AGENT_NAME=%i`. |
|
||||
| session launcher | `tools/fleet/start-agent-session.sh <role>` | Builds the launch command, opens the tmux pane, wires the heartbeat. |
|
||||
| launch command | `mosaic yolo <runtime>` (or a per-agent override) | Replaces the pane's foreground process with the runtime, fully seeded. |
|
||||
| seeding | `mosaic`'s `composeContract()` | Injects the Constitution/USER/TOOLS/runtime contract, `*.local` overlays, **and** the Fleet-Comms cheat-sheet — all via `--append-system-prompt`. |
|
||||
|
||||
The launcher never `source`s or `eval`s an environment file and never accepts an environment-supplied
|
||||
command. `MOSAIC_AGENT_COMMAND`, command/channel overrides, unknown keys, generated-key shadowing,
|
||||
secret-like key names, duplicate keys, comments, quoted/export syntax, and unsafe values are rejected.
|
||||
Per-agent overrides live in `fleet/agents/<role>.env`, generated from `roster.yaml` by
|
||||
`generateAgentEnv` (`packages/mosaic/src/commands/fleet.ts`) and consumed by the launcher.
|
||||
|
||||
## Generated and local files
|
||||
## Worker launch path (default)
|
||||
|
||||
`<name>.env.generated` is complete, deterministic, and written only by Mosaic. Its ordered keys are:
|
||||
1. `roster.yaml` carries each agent's `runtime` and optional `model_hint`.
|
||||
2. `generateAgentEnv` emits `fleet/agents/<role>.env` with `MOSAIC_AGENT_NAME`,
|
||||
`MOSAIC_AGENT_RUNTIME`, and `MOSAIC_AGENT_MODEL`.
|
||||
3. `start-agent-session.sh` has no `MOSAIC_AGENT_COMMAND` set, so it falls through to the default
|
||||
(line ~44):
|
||||
```sh
|
||||
MOSAIC_AGENT_COMMAND="mosaic yolo $MOSAIC_AGENT_RUNTIME${MOSAIC_AGENT_MODEL:+ --model $MOSAIC_AGENT_MODEL}"
|
||||
```
|
||||
4. The launcher bakes `MOSAIC_AGENT_NAME` into the pane command (line ~118), so `composeContract`
|
||||
can inject the Fleet-Comms cheat-sheet for that role.
|
||||
|
||||
```dotenv
|
||||
MOSAIC_AGENT_NAME=<roster name>
|
||||
MOSAIC_AGENT_CLASS=<roster class>
|
||||
MOSAIC_AGENT_RUNTIME=<roster runtime>
|
||||
MOSAIC_AGENT_MODEL=<roster model hint>
|
||||
MOSAIC_AGENT_REASONING=<roster reasoning>
|
||||
MOSAIC_AGENT_TOOL_POLICY=<roster tool policy>
|
||||
MOSAIC_AGENT_WORKDIR=<absolute roster work directory>
|
||||
MOSAIC_TMUX_SOCKET=<roster socket or empty>
|
||||
That is the whole worker path: roster → `.env` → `mosaic yolo <runtime>` → seeded pane.
|
||||
|
||||
## Orchestrator fold (PATH A — ships today)
|
||||
|
||||
The orchestrator is **just another roster agent** launched through the canonical path — not a
|
||||
snowflake script.
|
||||
|
||||
| Piece | Value |
|
||||
| ------------------ | ----------------------------------- |
|
||||
| host-side launcher | `orchestrator-launch.sh` |
|
||||
| systemd unit | `mosaic-fleet-orchestrator.service` |
|
||||
| tmux session | `orchestrator` (role-named) |
|
||||
|
||||
Set its launch command via `fleet/agents/orchestrator.env`:
|
||||
|
||||
```sh
|
||||
MOSAIC_AGENT_COMMAND='mosaic yolo claude --channels plugin:discord@<channel>'
|
||||
```
|
||||
|
||||
The generated launch contract supports `claude`, `codex`, `opencode`, and `pi`. `mosaic fleet add`
|
||||
rejects another runtime before it writes the roster or modifies generated, local, or quarantine state.
|
||||
The legacy dogfood stub remains an observability-only canary on its separate `mosaic-factory` socket;
|
||||
it has no generated-launch adapter and cannot be added through this path.
|
||||
When `MOSAIC_AGENT_COMMAND` is set, `start-agent-session.sh`'s `if [ -z "$MOSAIC_AGENT_COMMAND" ]`
|
||||
guard (line ~41) is false, so the line-44 default — **including its hardcoded `yolo`** — is skipped
|
||||
entirely. The override fully controls the runtime and flags. Routing through `mosaic yolo claude`
|
||||
(rather than a raw `claude` invocation) is what gives the orchestrator the same full
|
||||
`composeContract` seeding + Fleet-Comms cheat-sheet as every worker, with `--channels` and any
|
||||
other flags passed straight through to the `claude` binary.
|
||||
|
||||
`<name>.env.local` is optional and may contain only non-secret machine data:
|
||||
## Launch gotchas
|
||||
|
||||
- `MOSAIC_RUNTIME_BIN`
|
||||
- `MOSAIC_HEARTBEAT_RUN_DIR`
|
||||
- `MOSAIC_HEARTBEAT_INTERVAL`
|
||||
- `MOSAIC_CLAUDE_JSON`
|
||||
- `CLAUDE_CONFIG_DIR`
|
||||
1. **Flag conflict.** `mosaic yolo claude` already injects `--dangerously-skip-permissions`. Do
|
||||
**not** also pass `--permission-mode bypassPermissions` — the `claude` binary would receive both.
|
||||
Use `mosaic yolo claude …` alone (yolo covers the unattended posture), **or** non-yolo
|
||||
`mosaic claude --permission-mode bypassPermissions …`. Never mix the two.
|
||||
2. **`MOSAIC_AGENT_NAME` must reach the pane.** The launcher bakes it from the instance name, and
|
||||
`composeContract` gates the Fleet-Comms block on it (`launch.ts`, in `composeContract`) — **and**
|
||||
the role must be a member of `roster.yaml`, or the block resolves empty.
|
||||
3. **`launchRuntime` guards.** `mosaic yolo claude` runs `checkSoul` / `checkRuntime` /
|
||||
`checkSequentialThinking`. The host needs `SOUL.md` and the sequential-thinking MCP, or the
|
||||
launch aborts (a raw `claude` invocation skipped these checks). Dry-run the composed command in a
|
||||
throwaway tmux session before swapping a live launcher.
|
||||
|
||||
Paths must be safe absolute paths and the heartbeat interval must be a positive integer. Projection,
|
||||
local, and quarantine files must be private regular files; the managed directories must be real,
|
||||
private, non-symlink paths. Violations fail closed before tmux interaction.
|
||||
## Why per-agent `.env` survives upgrades (#632)
|
||||
|
||||
## Legacy input and diagnostics
|
||||
`install.sh` `PRESERVE_PATHS` includes `fleet/*.yaml`, `fleet/agents`, and `fleet/run`, so
|
||||
`mosaic update`'s framework re-seed **preserves** your roster and per-agent `.env` overrides
|
||||
(glob-aware `cp` fallback; matching TS parity in `file-adapter.ts`). Before #632, an auto re-seed
|
||||
could wipe them — which is exactly why PATH A's `.env` override is safe to rely on now.
|
||||
|
||||
A legacy `<name>.env` is input only during projection generation. Roster-owned keys are regenerated;
|
||||
valid allowed local data can move to `.env.local`; invalid legacy input is privately retained at
|
||||
`<name>.env.quarantine`. Neither legacy nor quarantine files are launch authority.
|
||||
## Inspecting the comms wiring
|
||||
|
||||
Diagnostics expose only rule code, key name, and a SHA-256 content hash. They do not reveal command
|
||||
text, credentials, or other values.
|
||||
- `mosaic fleet comms-block <role>` prints the Fleet-Comms cheat-sheet a given role receives at
|
||||
launch — its `[host:session]` identity, the exact `agent-send.sh` command for each peer, and the
|
||||
FLIP / `--verify` conventions. `--host <h>` previews a cross-host view. An unknown role or missing
|
||||
roster **fails loud** (stderr + non-zero exit), so a typo is never a silent no-op.
|
||||
- Versus `mosaic compose-contract <runtime>`: that emits the **whole** system prompt and reads the
|
||||
role from `MOSAIC_AGENT_NAME` (a full-prompt smoke test). `comms-block` is the targeted,
|
||||
explicit-arg, comms-only view — e.g. `mosaic fleet comms-block coder0-0` to preview a peer.
|
||||
|
||||
## Launch and stop behavior
|
||||
## North Star / future direction
|
||||
|
||||
The launcher obtains the agent's socket only from the validated generated projection. It creates or
|
||||
checks the exact `=<agent-name>` tmux target; it never uses an ambient socket or fuzzy session match.
|
||||
The same strict parser runs before exact-stop behavior. A fresh native Pi heartbeat remains authoritative;
|
||||
the shell sidecar only provides fallback state when the native marker is stale or absent.
|
||||
**Vision:** a webUI lets the user edit each agent's launch config — switch **harness**
|
||||
(claude / pi / codex / opencode), toggle **yolo**, pick a **model**, set a **command/channels**
|
||||
override — with no terminal.
|
||||
|
||||
`mosaic fleet comms-block <role>` can inspect the role's resolved Fleet-Comms block. It is a read-only
|
||||
inspection tool and fails loudly for an unknown role or missing roster.
|
||||
**Continuity — this is not a new launch path.** It is a data-model + UI-binding layer over the
|
||||
existing roster-driven launcher. Field-by-field status today:
|
||||
|
||||
## Current M2 boundary
|
||||
| Launch-config field | Roster-native today? | Mechanism / gap |
|
||||
| ------------------------ | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **harness** (`runtime`) | ✅ end-to-end | `roster.runtime` → `generateAgentEnv` emits `MOSAIC_AGENT_RUNTIME` → launcher line 44. UI just writes the field. |
|
||||
| **model** (`model_hint`) | ✅ end-to-end | `roster.model_hint` → `MOSAIC_AGENT_MODEL` → launcher line 44 `--model`. UI just writes the field. |
|
||||
| **yolo** | ❌ new | Launcher line 44 **hardcodes** `mosaic yolo`. A non-yolo toggle needs a roster `yolo` field → emit `MOSAIC_AGENT_YOLO` → make line 44 conditional. |
|
||||
| **command / channels** | ❌ new | `MOSAIC_AGENT_COMMAND` is **consumed** (launcher line ~12) but `generateAgentEnv` does not emit it. Needs a roster `command`/`channels` field → emitted. |
|
||||
|
||||
FCM-M2-001 supplies generated/local parsing, validation, projection, quarantine, and launch-boundary
|
||||
evidence only. It does not authorize roster CRUD expansion, reconciliation, lifecycle changes, remote
|
||||
or connector mutation, site canaries, or migration. M3 must establish the local reconcile/lifecycle
|
||||
path; M4 separately provides migration preview, canary, and rollback gates.
|
||||
**The arc:**
|
||||
|
||||
- **A** — `.env` `MOSAIC_AGENT_COMMAND` hatch: manual, ships now, kept safe across upgrades by #632.
|
||||
- **B** — roster-native launch-config: harness + model are already there; add the **yolo** toggle
|
||||
(line-44 conditional) and **command/channels** emission to complete the data model.
|
||||
- **webUI** — binds dropdowns/toggles directly to those four roster fields.
|
||||
|
||||
PATH A's `.env` override is the **manual form** of exactly what PATH B makes roster-native and the
|
||||
webUI edits — one continuous arc, not three separate features. PATH B is tracked as #636.
|
||||
|
||||
@@ -7,8 +7,7 @@ The v2 compiler may not silently accept an unresolved class. Before M1 exits, ev
|
||||
below must be either migrated and executable, retained as an explicitly versioned v1 fixture, or
|
||||
retired with a replacement/deprecation note. Class resolution must use the existing
|
||||
profile/persona/provision baseline-plus-`roles.local` resolver; this inventory does not create a
|
||||
parallel resolver. The current executable implementation and per-artifact outcomes are recorded in
|
||||
[the disposition evidence](./migration/example-profile-disposition.md).
|
||||
parallel resolver.
|
||||
|
||||
## Examples
|
||||
|
||||
|
||||
@@ -14,17 +14,18 @@ and surfaced as `mosaic fleet backlog <sub> --json`.
|
||||
The backlog uses the existing Mosaic storage layer; there is **no** new database
|
||||
engine (no sqlite, no raw client).
|
||||
|
||||
| Condition | Tier | Data location |
|
||||
| ---------------------------------- | -------------------- | ---------------------------------------------------------------- |
|
||||
| `DATABASE_URL` injected at runtime | Full server Postgres | the verified runtime database; it never authorizes migration/DDL |
|
||||
| `PGLITE_DATA_DIR` set (no URL) | Embedded PGlite | that directory |
|
||||
| neither (default) | Embedded PGlite | `~/.config/mosaic/fleet/backlog` |
|
||||
| Condition | Tier | Data location |
|
||||
| ------------------------------ | -------------------- | -------------------------------- |
|
||||
| `DATABASE_URL` set | Full server Postgres | the configured database |
|
||||
| `PGLITE_DATA_DIR` set (no URL) | Embedded PGlite | that directory |
|
||||
| neither (default) | Embedded PGlite | `~/.config/mosaic/fleet/backlog` |
|
||||
|
||||
PGlite is real Postgres semantics in-process — including the row locks the atomic
|
||||
claim relies on — so the **same code** runs on a laptop (embedded, single-host
|
||||
default) and on a full Postgres deployment. Switching tiers is config-only.
|
||||
|
||||
For embedded PGlite only, the local backlog routine may prepare its local schema on first use. **Current operator behavior is PGlite-only.** The PostgreSQL path is held until KBN-101 activation; no current PostgreSQL CLI route, runner, or first-use migration is available or authorized. A future activated PostgreSQL runtime may connect only after its separately certified readiness gate.
|
||||
The schema (`backlog` table) is created automatically on first CLI use:
|
||||
`runMigrations()` for Postgres, `runPgliteMigrations()` for embedded PGlite.
|
||||
|
||||
### Update safety
|
||||
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
# Create, Inspect, Update, and Delete a Local Fleet Agent
|
||||
|
||||
Use the local roster-v2 control plane only. These commands change desired state and derived environment projections; they never start, stop, reconcile, inspect, or otherwise act on systemd, tmux, sessions, or runtimes.
|
||||
|
||||
## Read and plan first
|
||||
|
||||
```sh
|
||||
mosaic fleet get <name>
|
||||
mosaic fleet plan create --expected-generation <n> --agent '<json>'
|
||||
mosaic fleet plan update <name> --expected-generation <n> --agent '<json>'
|
||||
mosaic fleet plan delete <name> --expected-generation <n>
|
||||
```
|
||||
|
||||
`plan create` takes the name from `--agent`. `plan update` and `plan delete` require the target name immediately after the operation. A plan is deterministic and side-effect free: it validates the complete proposed roster and projection targets without changing files. Use `--dry-run` on `create`, `update`, or `delete` for the same no-write result.
|
||||
|
||||
Every successful command prints JSON. `get` returns `{ "generation", "agent" }`; mutation results contain `plan`, `applied`, `authoritativeRoster`, and `projections`.
|
||||
|
||||
## Create safely
|
||||
|
||||
```sh
|
||||
mosaic fleet create --expected-generation 7 --agent '{
|
||||
"name":"coder0",
|
||||
"alias":"Coder 0",
|
||||
"className":"code",
|
||||
"runtime":"pi",
|
||||
"provider":"openai",
|
||||
"model":"gpt-5.6-sol",
|
||||
"reasoning":"high",
|
||||
"toolPolicy":"code",
|
||||
"workingDirectory":"/srv/mosaic",
|
||||
"persistentPersona":false,
|
||||
"resetBetweenTasks":true,
|
||||
"launch":{"yolo":true}
|
||||
}'
|
||||
```
|
||||
|
||||
Create defaults to `enabled: true` and `desired_state: stopped`. It does not start a process. Add `--persisted-start` only to persist `desired_state: running`; that still does not start a runtime in this M2 command. The JSON payload is an allowlist of the roster-v2 fields shown above plus `launch.yolo`; command, channel, secret-reference, and other unknown keys are rejected rather than ignored. The JSON error exposes only a stable code, never the rejected value.
|
||||
|
||||
## Update and delete safely
|
||||
|
||||
```sh
|
||||
mosaic fleet update coder0 --expected-generation 8 --agent '<complete JSON agent payload>'
|
||||
mosaic fleet delete coder0 --expected-generation 9
|
||||
```
|
||||
|
||||
Updates require a complete agent JSON payload and preserve the stable name. Delete removes only the exact roster-owned `coder0.env.generated` projection. It retains `coder0.env.local`, legacy `coder0.env`, `coder0.env.quarantine`, and every unrelated projection. A delete dry-run leaves all of those files byte-identical.
|
||||
|
||||
## Handle generation conflicts
|
||||
|
||||
Every mutation requires the current authoritative `--expected-generation`. A stale value returns JSON `error.code: "stale-generation"` with a non-zero exit. Reload with `mosaic fleet get <name>` or reread the roster, plan again using the returned generation, then retry. A concurrent mutation returns `concurrent-mutation`; do not force or bypass the lock.
|
||||
|
||||
## Interpret partial failures
|
||||
|
||||
The roster is authoritative and is written before derived projections. A late projection I/O failure returns non-zero with redacted, actionable JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"applied": false,
|
||||
"authoritativeRoster": "committed",
|
||||
"projections": "incomplete",
|
||||
"recovery": {
|
||||
"code": "projection-apply-failed",
|
||||
"action": "regenerate-projections-from-roster"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This is not a rollback and not a no-op: reload the roster because its generation and membership were committed, regenerate projections from that roster, then plan a new mutation. Recovery output never contains environment values, credentials, or command text.
|
||||
|
||||
## Exit and boundary behavior
|
||||
|
||||
Handled validation errors and partial projection failures exit non-zero. `plan`/`--dry-run` and normal mutation JSON make the state explicit; scripts should use both the exit code and `authoritativeRoster`/`projections`, not `applied` alone.
|
||||
|
||||
The commands operate only on `<mosaic-home>/fleet/roster.yaml`, the local roster desired-state authority. They do not accept arbitrary commands, channels, secrets, remote/connector actions, migration/canary actions, or runtime lifecycle operations.
|
||||
@@ -1,54 +0,0 @@
|
||||
# Customize Fleet Roles
|
||||
|
||||
Mosaic resolves persona contracts through two layers:
|
||||
|
||||
1. `fleet/roles/<canonical-class>.md` — seeded baseline contract.
|
||||
2. `fleet/roles.local/<canonical-class>.md` — operator override or custom role; this layer wins.
|
||||
|
||||
The same shared resolver is used by profile validation, provisioning, roster-v2 semantic validation,
|
||||
and launch-time persona injection.
|
||||
|
||||
## Override a baseline role
|
||||
|
||||
Create a readable Markdown contract under `roles.local` with the canonical filename and class marker:
|
||||
|
||||
```markdown
|
||||
# Code — local role definition
|
||||
|
||||
The local code role (`class: code`) follows the operator's repository conventions.
|
||||
```
|
||||
|
||||
Save it as `fleet/roles.local/code.md`. Do not edit generated or seeded baseline assets when the goal
|
||||
is a durable local customization.
|
||||
|
||||
Legacy aliases canonicalize before lookup. Therefore `roles.local/implementer.md` does not override
|
||||
`code`; use `roles.local/code.md`. See [Legacy Fleet Class Aliases](../migration/legacy-class-aliases.md).
|
||||
|
||||
## Add a custom class
|
||||
|
||||
A custom class remains supported when a readable contract exists for the exact identifier:
|
||||
|
||||
```markdown
|
||||
# Release notes — local role definition
|
||||
|
||||
The release-notes role (`class: release-notes`) prepares operator-reviewed release copy.
|
||||
```
|
||||
|
||||
Save it as `fleet/roles.local/release-notes.md`, then reference `class: release-notes` and a matching
|
||||
`tool_policy: release-notes` in roster v2. Adding only a `LIBRARY.md` row is insufficient.
|
||||
|
||||
Names such as `worker`, `analyst`, and `canary` are not built-in aliases; they need genuine custom
|
||||
contracts. `agents[].alias`, Tess, and Ultron are display names and cannot select a class.
|
||||
|
||||
## Validation and authority boundaries
|
||||
|
||||
Semantic validation reads the winning contract and rejects missing, unreadable, or empty files.
|
||||
Protected authority is derived from canonical class metadata in code, never from role prose. A custom
|
||||
contract cannot claim merge, validation-certificate, orchestration, lease, or interaction authority.
|
||||
|
||||
Roster v2 also fails closed when a protected class and tool policy do not match after canonicalization,
|
||||
or when an unprotected class claims a protected tool policy. The legacy `operator-interaction` policy
|
||||
canonicalizes to `interaction`.
|
||||
|
||||
Role customization does not issue leases, store validation certificates, mutate credentials, or
|
||||
change lifecycle state.
|
||||
@@ -1,23 +0,0 @@
|
||||
# Safely Reconcile and Control a Local Fleet Agent
|
||||
|
||||
Use the canonical local roster-v2 command surface:
|
||||
|
||||
```sh
|
||||
mosaic fleet apply --expected-generation <n> --dry-run
|
||||
mosaic fleet apply --expected-generation <n>
|
||||
mosaic fleet reconcile --expected-generation <n>
|
||||
mosaic fleet start <name> --expected-generation <n>
|
||||
mosaic fleet stop <name> --expected-generation <n>
|
||||
mosaic fleet restart <name> --expected-generation <n>
|
||||
mosaic fleet status [name]
|
||||
mosaic fleet verify
|
||||
mosaic fleet doctor
|
||||
```
|
||||
|
||||
Start with `--dry-run`. It validates roster semantics, deterministic projections, private managed paths, exact holder ownership, and named-socket state without changing files or lifecycle state. `apply` and `reconcile` rebuild derived projections and enforce only persisted roster state: enabled `running` agents may start, while stopped or disabled agents are not started.
|
||||
|
||||
`start`, `stop`, and `restart` are explicit one-shot exact-service actions. They do not persist a lifecycle change. Roster CRUD is the only way to change persisted desired state.
|
||||
|
||||
Every command prints JSON. Observation commands report drift without mutation; `verify` exits non-zero on ownership mismatch, unmanaged sessions, or drift. A failed apply that wrote some derived projections reports `projections: "incomplete"` with bounded recovery to regenerate from the roster. A lifecycle failure after projections reports incomplete lifecycle work; it is never represented as a rollback or no-op.
|
||||
|
||||
These commands are local only. Remote/SSH/connector entries are inventory/validation-only. Commands do not accept arbitrary runtime commands, channels, secrets, generated-file desired state, or arbitrary tmux sockets.
|
||||
@@ -1,52 +0,0 @@
|
||||
# Executable Fleet Example, Profile, and Service-Preset Dispositions
|
||||
|
||||
**Issue:** #758 · **Card:** FCM-M1-003 · **Status:** M1 executable disposition evidence
|
||||
|
||||
This document records the executable disposition for every currently shipped fleet YAML artifact.
|
||||
The authoritative baseline classification remains the
|
||||
[legacy inventory](../LEGACY-EXAMPLE-PROFILE-DISPOSITION-INVENTORY.md). The executable guard is
|
||||
`packages/mosaic/src/fleet/example-profile-dispositions.ts`; its test fails if a shipped YAML
|
||||
artifact is added, removed, or left without one of the dispositions below.
|
||||
|
||||
## Disposition rules
|
||||
|
||||
- **Explicit v1 fixture:** the artifact is loaded through the existing v1 roster parser and must
|
||||
declare `version: 1`. It remains a compatibility fixture; it is not silently treated as a v2
|
||||
roster or given inferred aliases.
|
||||
- **Canonical profile:** the artifact is loaded through `loadProfiles`, which uses the shared
|
||||
baseline-plus-`roles.local` persona resolver and rejects unreadable or unresolved classes.
|
||||
- **Canonical service policy:** the artifact is loaded through the operator-interaction service
|
||||
policy reader and provisioned with a generic supplied identity. It validates its runtime, model,
|
||||
reasoning, and legacy tool-policy compatibility without hardcoding a product identity.
|
||||
|
||||
No artifact is retired in this card. A later retirement requires both a replacement link and a
|
||||
visible deprecation note; the executable guard must then record the new disposition before the
|
||||
artifact can be removed.
|
||||
|
||||
## Shipped artifacts
|
||||
|
||||
| Artifact | Disposition | Executable path | Compatibility notes |
|
||||
| ------------------------------------ | ------------------------ | --------------------------------- | ------------------------------------------------------------------------------------------------ |
|
||||
| `examples/coding.yaml` | Explicit v1 fixture | v1 roster parser | Retains approved `implementer` and `reviewer` compatibility inputs. |
|
||||
| `examples/general.yaml` | Explicit v1 fixture | v1 roster parser | Retains unresolved `worker` without an inferred canonical role. |
|
||||
| `examples/hybrid.yaml` | Explicit v1 fixture | v1 roster parser | Retains `implementer`, `reviewer`, and resolver-dependent `researcher`. |
|
||||
| `examples/local-canary.yaml` | Explicit v1 fixture | v1 roster parser | Retains the local-tmux canary topology. |
|
||||
| `examples/minimal.yaml` | Explicit v1 fixture | v1 roster parser | Retains `canary` without an inferred canonical role. |
|
||||
| `examples/operator-interaction.yaml` | Explicit v1 fixture | v1 roster parser | Keeps Tess only as an example instance name; `operator-interaction` remains compatibility input. |
|
||||
| `examples/research.yaml` | Explicit v1 fixture | v1 roster parser | Retains resolver-dependent `researcher` and `analyst`. |
|
||||
| `profiles/business.yaml` | Canonical profile | shared profile/persona resolver | Every referenced business class must resolve to a readable contract. |
|
||||
| `profiles/marketing.yaml` | Canonical profile | shared profile/persona resolver | Every referenced marketing class must resolve to a readable contract. |
|
||||
| `profiles/personal-assistant.yaml` | Canonical profile | shared profile/persona resolver | No interaction equivalence is inferred. |
|
||||
| `profiles/research.yaml` | Canonical profile | shared profile/persona resolver | Every research class must resolve to a readable contract. |
|
||||
| `profiles/software-delivery.yaml` | Canonical profile | shared profile/persona resolver | Retains the governance profile; authority validation remains FCM-M1-002 evidence. |
|
||||
| `services/operator-interaction.yaml` | Canonical service policy | service-policy reader/provisioner | Generic provisioning supplies the instance name; the policy itself never names Tess. |
|
||||
|
||||
## Running the guard
|
||||
|
||||
```bash
|
||||
pnpm --filter @mosaicstack/mosaic test -- example-profile-dispositions.spec.ts
|
||||
```
|
||||
|
||||
The guard is intentionally limited to shipped assets and validation. It does not generate
|
||||
environment files, mutate a roster, reconcile a fleet, migrate an installed roster, or launch an
|
||||
agent.
|
||||
@@ -1,40 +0,0 @@
|
||||
# Legacy Fleet Class Aliases
|
||||
|
||||
Fleet class compatibility is intentionally narrow. The shared resolver accepts exactly three legacy
|
||||
class names and converts them to canonical classes before persona lookup:
|
||||
|
||||
| Legacy value | Canonical value | Migration action |
|
||||
| ---------------------- | --------------- | ---------------------------------------------------------------------------------------------------------------------- |
|
||||
| `implementer` | `code` | Replace class and tool-policy references with `code`. |
|
||||
| `reviewer` | `review` | Replace class and tool-policy references with `review`. |
|
||||
| `operator-interaction` | `interaction` | Replace class and roster-v2 tool-policy references with `interaction`. The legacy service artifact remains compatible. |
|
||||
|
||||
Alias support preserves existing inputs while provisioning and typed semantic output use canonical
|
||||
identities. Requested and canonical class values remain separately observable during semantic
|
||||
validation.
|
||||
|
||||
## Lookup and override behavior
|
||||
|
||||
Canonicalization precedes baseline and `roles.local` lookup. A legacy-named override such as
|
||||
`roles.local/implementer.md` is not a separate authority and is not selected for an `implementer`
|
||||
request. Customize the canonical role instead, for example `roles.local/code.md`.
|
||||
|
||||
The compatibility file `operator-interaction.md` remains shipped, but `interaction` is the canonical
|
||||
role class. Tess is an example display name only.
|
||||
|
||||
## Unresolved and custom classes
|
||||
|
||||
No names are inferred from historical usage, instance names, or similar wording. `worker`, `analyst`,
|
||||
`canary`, Tess, and Ultron are not aliases. An otherwise unknown class is accepted only if the shared
|
||||
resolver can read an actual baseline or `roles.local` contract for that exact class. A `LIBRARY.md`
|
||||
row without a readable contract fails semantic validation.
|
||||
|
||||
Custom classes receive no protected authority implicitly. Protected class/tool-policy mismatches
|
||||
fail closed.
|
||||
|
||||
## Retirement guidance
|
||||
|
||||
New configuration should emit canonical values. Existing inputs may use the three aliases during the
|
||||
compatibility period, but operators should migrate class and tool-policy fields together. Do not
|
||||
create new legacy-named role overrides; move their intended content to the canonical filename and
|
||||
validate the roster/profile before removing the old artifact.
|
||||
@@ -1,26 +0,0 @@
|
||||
# Reconcile and Recover a Local Fleet
|
||||
|
||||
## Safe sequence
|
||||
|
||||
1. Read `mosaic fleet doctor` and `mosaic fleet status`.
|
||||
2. Run `mosaic fleet apply --expected-generation <n> --dry-run`.
|
||||
3. Resolve stale generation, ownership mismatch, unsafe path, projection validation, or unmanaged-session findings before applying.
|
||||
4. Run `mosaic fleet apply --expected-generation <n>` only after the plan is understood.
|
||||
|
||||
The reconciler uses the exact roster tmux socket, exact holder session, private installation holder identity, and the complete expected global environment. For mutations it acquires its exclusive lock before rereading the canonical roster and fencing its generation; only that under-lock roster drives validation, planning, projections, and lifecycle effects. Before effects, its exclusive lock proves real private `MOSAIC_HOME` and `fleet` ancestors, uses a private `0600` lock leaf, and binds cleanup to the created file identity and ownership token. A fake holder, contaminated global environment, missing identity, unsafe lock path, or unmanaged session fails closed. It does not adopt, kill, or rename any unproven session. A crash can leave a stale lock for explicit operator inspection; reconciliation deliberately does not guess ownership or remove it.
|
||||
|
||||
## Partial results
|
||||
|
||||
The roster is never changed by reconciliation. If derived projection application partially fails, JSON reports:
|
||||
|
||||
```json
|
||||
{
|
||||
"applied": false,
|
||||
"authoritativeRoster": "unchanged",
|
||||
"projections": "incomplete",
|
||||
"lifecycle": "not-applied",
|
||||
"recovery": { "code": "projection-apply-failed", "action": "regenerate-projections-from-roster" }
|
||||
}
|
||||
```
|
||||
|
||||
If projections completed but lifecycle work failed, JSON reports `projections: "complete"`, `lifecycle: "incomplete"`, and the bounded action `rerun-after-inspecting-owned-resources`. If lock cleanup cannot be proven after an effect result, it adds `cleanup: { "code": "lock-cleanup-failed", "action": "inspect-lock-before-retry" }` without changing the known projection, lifecycle, or primary recovery truth. Inspect the retained lock before retrying; no rollback, release, or stale-lock removal is implied. Results do not include environment values, secrets, or privileged command content.
|
||||
@@ -1,43 +0,0 @@
|
||||
# Local Fleet Agent Mutations
|
||||
|
||||
FCM-M2-002 provides local roster-v2 create, get, update, delete, and plan operations. They only change desired state and derived environment projections. They never start, stop, inspect, reconcile, or otherwise act on runtimes, systemd units, tmux sessions, or heartbeats.
|
||||
|
||||
## CLI contract
|
||||
|
||||
The commands operate only on the canonical `<mosaic-home>/fleet/roster.yaml` v2 authority and print one JSON object to stdout. `--agent` is a JSON object with the roster agent fields expressed as `className`, `toolPolicy`, `workingDirectory`, `persistentPersona`, `resetBetweenTasks`, and `launch: { "yolo": boolean }`.
|
||||
|
||||
```sh
|
||||
mosaic fleet get <name>
|
||||
mosaic fleet plan <create|update|delete> [name] --expected-generation <n> [--agent '<json>'] [--persisted-start]
|
||||
mosaic fleet create --expected-generation <n> --agent '<json>' [--dry-run] [--persisted-start]
|
||||
mosaic fleet update <name> --expected-generation <n> --agent '<json>' [--dry-run]
|
||||
mosaic fleet delete <name> --expected-generation <n> [--dry-run]
|
||||
```
|
||||
|
||||
`get` returns the authoritative generation and the selected agent. `plan create` derives its name from `--agent`; `plan update <name>` and `plan delete <name>` require the target name. `--agent` accepts only the documented roster-v2 request fields and `launch.yolo`; unknown keys such as commands, channels, or secret references are rejected. Rejection diagnostics return only the stable `invalid-request` code and never echo a rejected value. `plan` and `--dry-run` validate the complete proposed roster and projections but write neither the roster nor projections. `--persisted-start` is available only for a create request: it records `desired_state: running`, but does not start a process. Without it, create records `enabled: true` and `desired_state: stopped`. Handled failures return JSON with `error.code` and exit non-zero; unclassified validation/projection failures use the redacted `mutation-failed` code.
|
||||
|
||||
## Generation, validation, and idempotency
|
||||
|
||||
Each create, update, or delete request includes `expectedGeneration`. A request whose expected value differs from the authoritative roster generation fails with `stale-generation`; reload and retry with a newly computed plan. A private mutation lock rejects concurrent writers with `concurrent-mutation`.
|
||||
|
||||
`planFleetAgentMutation` is deterministic and side-effect free. `executeFleetAgentMutation` validates the complete proposed roster through the existing structural and shared persona resolver, prepares generated/local/quarantine projections, and writes the roster authority atomically before applying derived projections. Equivalent create retries and delete requests for an already-absent agent are idempotent no-ops.
|
||||
|
||||
Delete removes only the exact `<name>.env.generated` projection for the removed roster entry. Operator-owned `<name>.env.local`, legacy `<name>.env`, quarantine records, and unrelated projections remain untouched. An already-absent generated projection is treated as stale derived state, not as a failed mutation.
|
||||
|
||||
## Result and recovery
|
||||
|
||||
Mutation results are JSON-safe objects with `applied`, `authoritativeRoster`, `projections`, `plan`, and—only if a derived projection write fails after the authoritative roster write—a recovery object. `applied` is true only when every roster and derived-projection write completed. The explicit state fields prevent a partial result from being mistaken for a rollback or a no-op:
|
||||
|
||||
```json
|
||||
{
|
||||
"applied": false,
|
||||
"authoritativeRoster": "committed",
|
||||
"projections": "incomplete",
|
||||
"recovery": {
|
||||
"code": "projection-apply-failed",
|
||||
"action": "regenerate-projections-from-roster"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Dry-runs and idempotent no-ops report `authoritativeRoster: "unchanged"` and `projections: "not-applied"`; a complete mutation reports `"committed"` and `"complete"`. Recovery output identifies the authoritative roster path and regeneration action only. It never contains generated/local/quarantine values, credentials, or command text. A recovery result exits non-zero because the authoritative roster was persisted but derived projections require regeneration. Regenerate projections from the roster before attempting another mutation.
|
||||
@@ -1,20 +0,0 @@
|
||||
# Fleet Control-Plane CLI
|
||||
|
||||
The local roster-v2 control plane is `mosaic fleet`.
|
||||
|
||||
```text
|
||||
mosaic fleet apply --expected-generation <n> [--dry-run]
|
||||
mosaic fleet reconcile --expected-generation <n> [--dry-run]
|
||||
mosaic fleet start [name] --expected-generation <n> [--dry-run]
|
||||
mosaic fleet stop [name] --expected-generation <n> [--dry-run]
|
||||
mosaic fleet restart [name] --expected-generation <n> [--dry-run]
|
||||
mosaic fleet status [name]
|
||||
mosaic fleet verify
|
||||
mosaic fleet doctor
|
||||
```
|
||||
|
||||
`apply` and `reconcile` use roster desired state. `start`, `stop`, and `restart` are exact local one-shot lifecycle effects and never persist a desired-state edit. `status`, `verify`, and `doctor` are observational.
|
||||
|
||||
Commands emit one JSON object. Handled precondition errors emit `{ "error": { "code": "..." } }` and exit non-zero. Partial derived/lifecycle effects use explicit `authoritativeRoster`, `projections`, `lifecycle`, and bounded `recovery` fields; they never claim rollback. Any additive `cleanup` diagnostic also exits non-zero, even where known effects are complete: it is not a clean completion and the lock requires inspection before retry.
|
||||
|
||||
This control plane is separate from the gateway-backed `mosaic agent` catalog. It is local-only and rejects remote/connector lifecycle mutation, arbitrary command/channel/secret input, and unproven tmux ownership.
|
||||
@@ -1,96 +0,0 @@
|
||||
# Fleet Generated Environment Boundary
|
||||
|
||||
**Card:** FCM-M2-001 · **Issue:** #758 · **Status:** unreleased/card-local
|
||||
|
||||
The local fleet roster is the desired-state authority. A launch reads a deterministic,
|
||||
roster-derived generated projection and an optional strictly data-only local file; neither file is
|
||||
a second roster or a command configuration surface.
|
||||
|
||||
## Paths and ownership
|
||||
|
||||
For agent `<name>` under `<MOSAIC_HOME>/fleet/agents/`:
|
||||
|
||||
| Path | Owner | Purpose |
|
||||
| ----------------------- | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `<name>.env.generated` | Mosaic projection writer | Complete deterministic launch data rendered from the authoritative roster. |
|
||||
| `<name>.env.local` | Operator | Optional, constrained local machine data. It cannot shadow generated keys. |
|
||||
| `<name>.env` | Legacy input only | Read once during projection generation, then regenerated/relocated or privately quarantined. It is never a launch authority. |
|
||||
| `<name>.env.quarantine` | Mosaic quarantine | Mode-`0600` private record of forbidden legacy input; it is never read by the launcher. |
|
||||
|
||||
The systemd templates do not load either environment file. They invoke Bash with a fixed, cleared
|
||||
bootstrap environment; the launcher reads and validates `.env.generated` and `.env.local` itself before
|
||||
it queries, creates, or stops an exact tmux session. It does not `source`, `eval`, or execute an
|
||||
environment-supplied command. Exact stop derives its socket from the same validated generated projection,
|
||||
not from systemd or ambient environment data.
|
||||
|
||||
All projection, local, and quarantine files must be regular files with no group or world permissions.
|
||||
The agent environment directory must also be a real, non-symlink private directory; it is validated
|
||||
before either environment file is read or tmux is queried. Unsafe paths, symlinks, or permissions fail
|
||||
closed. Diagnostics identify only a rule code, key name, and SHA-256 content hash; they never print
|
||||
values, credential material, or command text.
|
||||
|
||||
## Allowed data
|
||||
|
||||
`.env.generated` is complete and ordered exactly as follows:
|
||||
|
||||
```dotenv
|
||||
MOSAIC_AGENT_NAME=<roster name>
|
||||
MOSAIC_AGENT_CLASS=<roster class>
|
||||
MOSAIC_AGENT_RUNTIME=<roster runtime>
|
||||
MOSAIC_AGENT_MODEL=<roster model hint>
|
||||
MOSAIC_AGENT_REASONING=<roster reasoning>
|
||||
MOSAIC_AGENT_TOOL_POLICY=<roster tool policy>
|
||||
MOSAIC_AGENT_WORKDIR=<absolute roster work directory>
|
||||
MOSAIC_TMUX_SOCKET=<roster socket or empty>
|
||||
```
|
||||
|
||||
The generated launch contract supports only `claude`, `codex`, `opencode`, and `pi`. `fleet add`
|
||||
uses that same runtime authority and rejects any other runtime before it writes the roster or changes
|
||||
projection, local, or quarantine files. The legacy dogfood stub on its separate `mosaic-factory`
|
||||
socket remains an observability canary; it has no generated-launch adapter and cannot be added through
|
||||
this projection path.
|
||||
|
||||
`.env.local` may contain only these non-secret data keys:
|
||||
|
||||
- `MOSAIC_RUNTIME_BIN`
|
||||
- `MOSAIC_HEARTBEAT_RUN_DIR`
|
||||
- `MOSAIC_HEARTBEAT_INTERVAL`
|
||||
- `MOSAIC_CLAUDE_JSON`
|
||||
- `CLAUDE_CONFIG_DIR`
|
||||
|
||||
Local paths must be safe absolute paths and the interval must be a positive integer. Comments,
|
||||
quoted/export syntax, duplicate keys, unknown keys, generated-key shadowing, sensitive key names,
|
||||
and `MOSAIC_AGENT_COMMAND` are rejected. The launcher derives the only executable command from the
|
||||
validated runtime, model, and reasoning data; no arbitrary command compatibility path exists. When a
|
||||
Pi runtime writes a fresh `<name>.hb.native` marker, its native heartbeat remains authoritative; the
|
||||
shell sidecar resumes its `status=ok` fallback only after that marker is stale or absent.
|
||||
|
||||
## Legacy disposition
|
||||
|
||||
During projection generation, legacy roster-derived keys are regenerated from the roster. A valid
|
||||
allowed local value is relocated to `.env.local`; forbidden, malformed, duplicate, sensitive, and
|
||||
unknown legacy entries cause the legacy file to be moved to `.env.quarantine` and are represented by
|
||||
sanitized diagnostics. This is deterministic and idempotent after the legacy file has been consumed.
|
||||
|
||||
## USC interface packet
|
||||
|
||||
This card does not add a USC site file, write a USC roster, or run a site canary. The following is the
|
||||
consolidated downstream interface packet. Status is deliberately separated from checkout presence: no
|
||||
product release version has been evidenced for this interface set.
|
||||
|
||||
| Interface | Canonical public path and version | Tracker/release status | Downstream limit |
|
||||
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
|
||||
| M1 structural compiler | `parseRosterV2` in `packages/mosaic/src/fleet/roster-v2.ts`; schema `docs/fleet/reference/roster-v2.schema.json`; roster `version: 2` | FCM-M1-001 is recorded done, merged as #764 (`aa5b43b`); no released product version is asserted here. | Parse YAML/JSON and canonicalize a supplied v2 site roster without writes. |
|
||||
| M1 semantic resolver | `validateRosterV2Semantics` in `packages/mosaic/src/fleet/roster-v2.ts`; baseline `framework/fleet/roles/` plus `roles.local/` | FCM-M1-002 remains `in-progress` in `docs/TASKS.md`; unreleased. | Reuse the shared resolver only; no parallel role resolver or lifecycle action. |
|
||||
| M1 disposition evidence | `packages/mosaic/src/fleet/example-profile-dispositions.ts`; `docs/fleet/migration/example-profile-disposition.md`; retained fixture `version: 1` | FCM-M1-003 remains `not-started` in `docs/TASKS.md`; unreleased even though these checkout artifacts are inspectable. | Inspect fixture/profile/service disposition evidence only; it is not migration authorization. |
|
||||
| M2 generated boundary | `packages/mosaic/src/fleet/generated-env-boundary.ts`; generated projection contract in this document | FCM-M2-001 card-local and uncommitted; unreleased. | Render/write a roster-derived projection; local input is never authority. |
|
||||
|
||||
The canonical source remains `<MOSAIC_HOME>/fleet/roster.yaml` for the current local fleet path.
|
||||
Generated environment data is a rebuildable projection, not an operator-editable source of membership,
|
||||
runtime policy, or lifecycle state.
|
||||
|
||||
**Corrected downstream gates:** M2 supplies only parse/validation/projection evidence and does not
|
||||
permit a USC site canary, reconciliation, or lifecycle mutation. M3 must first define and validate the
|
||||
canonical local reconcile/lifecycle path. M4 then supplies preview/migration and its separate
|
||||
canary/rollback gates; only after those M3 and M4 gates may a site migration or canary be considered.
|
||||
This card authorizes none of those actions.
|
||||
@@ -1,14 +0,0 @@
|
||||
# Local Fleet Lifecycle Transitions
|
||||
|
||||
FCM-M3-001 uses the roster-v2 `lifecycle.enabled` and `lifecycle.desired_state` fields as the only desired-state authority. Systemd, tmux, generated environment files, and heartbeats are derived or observed state.
|
||||
|
||||
| Command | Desired-state write | Runtime effect | Preconditions |
|
||||
| --------------------------------- | ------------------- | -------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `fleet apply` / `fleet reconcile` | Never | Rebuilds projections, then starts only enabled agents desired `running`; stops disabled or desired-`stopped` roster agents | Current generation; private managed paths; valid projections; proven holder ownership; no unmanaged named-socket sessions |
|
||||
| `fleet start <name>` | Never | One-shot exact `mosaic-agent@<name>.service` start | Current generation; exact enabled roster name; proven ownership |
|
||||
| `fleet stop <name>` | Never | One-shot exact service stop | Current generation; exact roster name; proven ownership |
|
||||
| `fleet restart <name>` | Never | One-shot exact service restart | Current generation; exact roster name; proven ownership |
|
||||
|
||||
A stopped roster agent is never started by `apply` or `reconcile`. Direct lifecycle commands are explicit one-shot actions and do not change persisted desired state. Use roster CRUD with the explicit persisted-start option to change that desired state.
|
||||
|
||||
All mutations require `--expected-generation <n>` and acquire one private roster-adjacent reconciliation lock before projection or lifecycle effects. Missing or stale generations and concurrent writers fail before effects; the lock is released after success, partial failure, or thrown lifecycle failure. Stale, ownership, unmanaged-session, unsupported-runtime, path, projection, and lifecycle-precondition failures return stable redacted JSON errors and a non-zero exit. No command targets a fuzzy tmux name, arbitrary socket, arbitrary command, channel, secret, or generated file as authority.
|
||||
@@ -1,45 +0,0 @@
|
||||
# Fleet Role Classes and Authority
|
||||
|
||||
A fleet role class is a machine identity resolved from the persona library. Resolution uses the
|
||||
canonical class before consulting the baseline `fleet/roles/` and operator `fleet/roles.local/`
|
||||
layers. A readable role contract is required; an index entry alone is not semantic success.
|
||||
|
||||
## Canonicalization
|
||||
|
||||
Only these legacy class aliases are recognized:
|
||||
|
||||
| Requested class | Canonical class |
|
||||
| ---------------------- | --------------- |
|
||||
| `implementer` | `code` |
|
||||
| `reviewer` | `review` |
|
||||
| `operator-interaction` | `interaction` |
|
||||
|
||||
No other alias is inferred. In particular, `worker`, `analyst`, and `canary` are custom classes only
|
||||
when an operator supplies a readable contract for that exact class. Tess and Ultron are instance
|
||||
names, not classes. `agents[].alias` is display-only and cannot grant authority.
|
||||
|
||||
Canonicalization happens before role lookup. For example, requesting `implementer` resolves
|
||||
`code.md`; a separate `roles.local/implementer.md` cannot redefine the legacy alias. A canonical
|
||||
`roles.local/code.md` still overrides the baseline `roles/code.md` contract.
|
||||
|
||||
## Protected authority
|
||||
|
||||
Protected authority is immutable metadata derived only from canonical class. Role prose, instance
|
||||
name, display alias, tool policy, runtime, and custom role files cannot grant it.
|
||||
|
||||
| Canonical class | Granted authority | Explicit limits |
|
||||
| ----------------- | -------------------------------------------------- | --------------------------------------------------------------------------------- |
|
||||
| `merge-gate` | Sole approve-to-land and merge authority | No authority is inferred by similarly named custom roles or policies. |
|
||||
| `validator` | May issue a validation certificate | Cannot approve-to-land or merge. |
|
||||
| `orchestrator` | May orchestrate, manage topology, and issue leases | Cannot approve-to-land or merge. |
|
||||
| `team-leader` | May use orchestrator-leased capacity | Cannot issue leases or mutate roster, configuration, credentials, or merge state. |
|
||||
| `interaction` | Request and status surface | Cannot orchestrate, issue leases, mutate roster/configuration, or merge. |
|
||||
| all other classes | No protected authority implicitly | Custom contracts do not acquire protected powers from prose. |
|
||||
|
||||
Roster-v2 semantic validation requires a protected class and its canonical tool policy to match. It
|
||||
also rejects an unprotected class paired with a protected tool policy. The legacy tool-policy name
|
||||
`operator-interaction` canonicalizes to `interaction`.
|
||||
|
||||
This mapping describes authority metadata only. Lease issuance, validation-certificate storage or
|
||||
workflow, lifecycle reconciliation, credentials, roster mutation, and merge execution are outside
|
||||
this resolver contract.
|
||||
@@ -81,35 +81,6 @@ agents:
|
||||
| `agents[].lifecycle.desired_state` | yes | `running` or `stopped` |
|
||||
| `agents[].launch.yolo` | yes | boolean; structured data only, not an arbitrary command escape hatch |
|
||||
|
||||
## Semantic handoff
|
||||
|
||||
`parseRosterV2` and `normalizeRosterV2` remain synchronous and structural. After structural success,
|
||||
call the asynchronous `validateRosterV2Semantics` handoff before using persona identity or authority.
|
||||
That validator batches the baseline `fleet/roles/` and operator `fleet/roles.local/` scans, then
|
||||
delegates every agent to the shared persona resolver.
|
||||
|
||||
Semantic validation:
|
||||
|
||||
- requires the winning role contract to be readable and non-empty; `LIBRARY.md` membership alone does
|
||||
not resolve a class;
|
||||
- retains `requestedClass` separately from `canonicalClass` in typed output;
|
||||
- canonicalizes only `implementer` to `code`, `reviewer` to `review`, and
|
||||
`operator-interaction` to `interaction`;
|
||||
- canonicalizes `tool_policy` with the same exact alias table;
|
||||
- rejects protected class/tool-policy mismatches in either direction, while accepting
|
||||
`class: operator-interaction` with `tool_policy: operator-interaction` as canonical
|
||||
`interaction`;
|
||||
- derives immutable protected authority only from canonical class; and
|
||||
- accepts custom baseline or `roles.local` classes without granting protected authority.
|
||||
|
||||
`agents[].alias` remains display-only. Tess and Ultron are instance names, never semantic classes.
|
||||
Canonicalization happens before role-layer lookup, so a legacy-named override cannot redefine an
|
||||
alias as separate authority. See [Role Classes and Authority](./role-classes.md) and
|
||||
[Customize Fleet Roles](../how-to/customize-roles.md).
|
||||
|
||||
This handoff performs no filesystem, systemd, tmux, roster, credential, lease, certificate, or
|
||||
lifecycle mutation.
|
||||
|
||||
## Fail-closed boundary
|
||||
|
||||
Every object is `additionalProperties: false`. The compiler rejects unknown, missing, malformed,
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
# Local Fleet Status and Drift
|
||||
|
||||
`mosaic fleet status [name]`, `verify`, and `doctor` are observational roster-v2 commands. They emit one JSON result and do not write projections, change desired state, start services, stop services, restart services, or mutate tmux.
|
||||
|
||||
The report distinguishes:
|
||||
|
||||
- `missing-session`: an enabled agent desired `running` has no exact roster-named tmux session.
|
||||
- `unexpected-session`: a desired-`stopped` agent still has its exact session.
|
||||
- `disabled-running`: a disabled roster agent has its exact session.
|
||||
- `unmanagedSessions`: sessions on the configured named socket that are neither the exact holder nor an exact roster agent.
|
||||
- `holder`: `owned`, `missing`, or `ownership-mismatch` after exact holder, private install identity, and complete global tmux environment validation.
|
||||
|
||||
`doctor` and `status` classify rather than adopt, destroy, or repair unmanaged state. `verify` is observational too, but exits non-zero if ownership cannot be proven, unmanaged sessions exist, or drift is present. Reconciliation fails closed under those conditions and never kills or adopts an unmanaged session.
|
||||
@@ -223,10 +223,10 @@ external clients. Authentication requires a valid BetterAuth session (cookie or
|
||||
|
||||
### Required
|
||||
|
||||
| Variable | Description |
|
||||
| -------------------- | ----------------------------------------------------------------------------------------------------------- |
|
||||
| `BETTER_AUTH_SECRET` | Secret key for BetterAuth session signing. Must be set or gateway will not start. |
|
||||
| `DATABASE_URL` | Runtime-only PostgreSQL connection injected from the dedicated deployment secret; no default or inline DSN. |
|
||||
| Variable | Description |
|
||||
| -------------------- | ----------------------------------------------------------------------------------------- |
|
||||
| `BETTER_AUTH_SECRET` | Secret key for BetterAuth session signing. Must be set or gateway will not start. |
|
||||
| `DATABASE_URL` | PostgreSQL connection string. Default: `postgresql://mosaic:mosaic@localhost:5433/mosaic` |
|
||||
|
||||
### Gateway
|
||||
|
||||
|
||||
@@ -1,67 +1,384 @@
|
||||
# Deployment Guide
|
||||
|
||||
> **Status: non-operative for PostgreSQL, federated, and bare-metal production.** The checked-in
|
||||
> Compose PostgreSQL service mounts legacy initialization SQL and the KBN-101 bootstrap, runner,
|
||||
> secret-renderer, and process-exec interfaces do not exist yet. This page does not authorize a
|
||||
> production deployment, database initialization, manual DDL, secret provisioning, or service
|
||||
> activation.
|
||||
This guide covers deploying Mosaic in two modes: **Docker Compose** (recommended for quick setup) and **bare-metal** (production, full control).
|
||||
|
||||
## Current safe local route
|
||||
---
|
||||
|
||||
Use PGlite only for current in-process data-layer work; it requires no PostgreSQL. A Gateway/Web
|
||||
local process is held because its unguarded dotenv loader can inherit a daemon PostgreSQL DSN and
|
||||
reach runtime DDL. If a local queue service is useful, start only Valkey:
|
||||
## Prerequisites
|
||||
|
||||
| Dependency | Minimum version | Notes |
|
||||
| ---------------- | --------------- | ---------------------------------------------- |
|
||||
| Node.js | 22 LTS | Required for ESM + `--experimental-vm-modules` |
|
||||
| pnpm | 9 | `npm install -g pnpm` |
|
||||
| PostgreSQL | 17 | Must have the `pgvector` extension |
|
||||
| Valkey | 8 | Redis-compatible; Redis 7+ also works |
|
||||
| Docker + Compose | v2 | For the Docker Compose path only |
|
||||
|
||||
---
|
||||
|
||||
## Docker Compose Deployment (Quick Start)
|
||||
|
||||
The `docker-compose.yml` at the repository root starts PostgreSQL 17 (with pgvector), Valkey 8, an OpenTelemetry Collector, and Jaeger.
|
||||
|
||||
### 1. Clone and configure
|
||||
|
||||
```bash
|
||||
docker compose up -d valkey
|
||||
git clone <repo-url> mosaic
|
||||
cd mosaic
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
This command intentionally does not start PostgreSQL. Do not run a broad Compose start, use its
|
||||
PostgreSQL initialization mount, infer that current Compose is a production/federated route, or
|
||||
start Gateway/Web until KBN-101-02 supplies fail-closed local-tier/DSN isolation.
|
||||
Edit `.env`. The minimum required change is:
|
||||
|
||||
## Held future procedure
|
||||
```dotenv
|
||||
BETTER_AUTH_SECRET=<output of: openssl rand -base64 32>
|
||||
```
|
||||
|
||||
PostgreSQL local, federated, Compose, and bare-metal production activation are held until these
|
||||
artifacts land and pass their independent gates:
|
||||
### 2. Start infrastructure services
|
||||
|
||||
1. **KBN-101-00** external privileged bootstrap artifact;
|
||||
2. **KBN-101-03** sole `mosaic-db-migrator` runner and verified-readiness artifact; and
|
||||
3. **KBN-101-05** Vault/secret-renderer-backed deployment and consumer-isolation artifact.
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
The required future order is external bootstrap → TLS/roles → `mosaic-db-migrator --run` → `mosaic-db-migrator --verify` → Gateway/Compose readiness.
|
||||
Services and their ports:
|
||||
|
||||
This is a held, non-operative future activation specification with no current command authority. Do not invoke the named
|
||||
runner, start PostgreSQL, or substitute a Compose/init/manual-SQL route until the owned artifacts
|
||||
are implemented and reviewed.
|
||||
| Service | Default port |
|
||||
| --------------------- | ------------------------ |
|
||||
| PostgreSQL | `localhost:5433` |
|
||||
| Valkey | `localhost:6380` |
|
||||
| OTEL Collector (HTTP) | `localhost:4318` |
|
||||
| OTEL Collector (gRPC) | `localhost:4317` |
|
||||
| Jaeger UI | `http://localhost:16686` |
|
||||
|
||||
## Future production secret and unit boundary (schematic only)
|
||||
Override host ports via `PG_HOST_PORT` and `VALKEY_HOST_PORT` in `.env` if the defaults conflict.
|
||||
|
||||
No current bare-metal production unit or command is published. KBN-101-05 must supply a reviewed,
|
||||
generation-pinned Vault renderer and a process-exec or systemd `LoadCredential` interface before
|
||||
production units can exist. The interface must preserve these exact consumer boundaries:
|
||||
### 3. Install dependencies
|
||||
|
||||
| Consumer | May receive | Must never receive |
|
||||
| ----------------- | -------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
|
||||
| Gateway/runtime | Its own runtime URL and DB client CA at process exec | Migrator URL, importer URL/version, attestation material, signing key, PostgreSQL private key |
|
||||
| One-shot migrator | Its own migration URL, DB client CA, and runner-only signing capability | Runtime URL, importer consumer copy, Gateway/private PostgreSQL keys |
|
||||
| Data importer | Its own immutable URL/version copies, importer CA, pinned public key, and sealed attestation | Runtime/migrator URLs, signing key, shared writable mount |
|
||||
| PostgreSQL | Its own server certificate/key and only its approved server material | Application, migrator, importer, or Gateway secrets |
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
A future unit specification is non-executable until KBN-101-05 supplies it. It must obtain
|
||||
credentials through the renderer’s Vault generation and process-exec/`LoadCredential` boundary;
|
||||
it must not place credentials in a production environment file, a monorepo auto-load path, a shell
|
||||
export, command arguments, logs, or a manual secret-activation lifecycle instruction. Rotation and
|
||||
process replacement semantics must be delivered by the reviewed renderer/interface with generation,
|
||||
consumer-isolation, mode/owner, and no-mixed-generation evidence—not improvised in this guide.
|
||||
### 4. Initialize the database
|
||||
|
||||
## Readiness and troubleshooting status
|
||||
```bash
|
||||
pnpm --filter @mosaicstack/db db:migrate
|
||||
```
|
||||
|
||||
Until the future procedure is implemented, do not diagnose PostgreSQL with ad hoc SQL, connection
|
||||
strings, or initialization scripts. The future sanitized runner-verification readiness artifact is
|
||||
the required PostgreSQL readiness authority after its bootstrap/TLS prerequisites pass.
|
||||
For local PGlite development, diagnose application behavior without introducing a PostgreSQL
|
||||
connection.
|
||||
### 5. Build all packages
|
||||
|
||||
Non-database local services may be inspected with their ordinary local health/log tools. Those
|
||||
checks do not certify PostgreSQL, federated deployment, or production readiness.
|
||||
```bash
|
||||
pnpm build
|
||||
```
|
||||
|
||||
### 6. Start the gateway
|
||||
|
||||
```bash
|
||||
pnpm --filter @mosaicstack/gateway dev
|
||||
```
|
||||
|
||||
Or for production (after build):
|
||||
|
||||
```bash
|
||||
node apps/gateway/dist/main.js
|
||||
```
|
||||
|
||||
### 7. Start the web app
|
||||
|
||||
```bash
|
||||
# Development
|
||||
pnpm --filter @mosaicstack/web dev
|
||||
|
||||
# Production (after build)
|
||||
pnpm --filter @mosaicstack/web start
|
||||
```
|
||||
|
||||
The web app runs on port `3000` by default.
|
||||
|
||||
---
|
||||
|
||||
## Bare-Metal Deployment
|
||||
|
||||
Use this path when you want to manage PostgreSQL and Valkey yourself (e.g., existing infrastructure, managed cloud databases).
|
||||
|
||||
### Step 1 — Install system dependencies
|
||||
|
||||
```bash
|
||||
# Node.js 22 via nvm
|
||||
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
|
||||
nvm install 22
|
||||
nvm use 22
|
||||
|
||||
# pnpm
|
||||
npm install -g pnpm
|
||||
|
||||
# PostgreSQL 17 with pgvector (Debian/Ubuntu example)
|
||||
sudo apt-get install -y postgresql-17 postgresql-17-pgvector
|
||||
|
||||
# Valkey
|
||||
# Follow https://valkey.io/download/ for your distribution
|
||||
```
|
||||
|
||||
### Step 2 — Create the database
|
||||
|
||||
```sql
|
||||
-- Run as the postgres superuser
|
||||
CREATE USER mosaic WITH PASSWORD 'change-me';
|
||||
CREATE DATABASE mosaic OWNER mosaic;
|
||||
\c mosaic
|
||||
CREATE EXTENSION IF NOT EXISTS vector;
|
||||
```
|
||||
|
||||
### Step 3 — Clone and configure
|
||||
|
||||
```bash
|
||||
git clone <repo-url> /opt/mosaic
|
||||
cd /opt/mosaic
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Edit `/opt/mosaic/.env`. Required fields:
|
||||
|
||||
```dotenv
|
||||
DATABASE_URL=postgresql://mosaic:<password>@localhost:5432/mosaic
|
||||
VALKEY_URL=redis://localhost:6379
|
||||
BETTER_AUTH_SECRET=<openssl rand -base64 32>
|
||||
BETTER_AUTH_URL=https://your-domain.example.com
|
||||
GATEWAY_CORS_ORIGIN=https://your-domain.example.com
|
||||
NEXT_PUBLIC_GATEWAY_URL=https://your-domain.example.com
|
||||
```
|
||||
|
||||
### Step 4 — Install dependencies and build
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm build
|
||||
```
|
||||
|
||||
### Step 5 — Run database migrations
|
||||
|
||||
```bash
|
||||
pnpm --filter @mosaicstack/db db:migrate
|
||||
```
|
||||
|
||||
### Step 6 — Start the gateway
|
||||
|
||||
```bash
|
||||
node apps/gateway/dist/main.js
|
||||
```
|
||||
|
||||
The gateway reads `.env` from the monorepo root automatically (via `dotenv` in `main.ts`).
|
||||
|
||||
### Step 7 — Start the web app
|
||||
|
||||
```bash
|
||||
# Next.js standalone output
|
||||
node apps/web/.next/standalone/server.js
|
||||
```
|
||||
|
||||
The standalone build is self-contained; it does not require `node_modules` to be present at runtime.
|
||||
|
||||
### Step 8 — Configure a reverse proxy
|
||||
|
||||
#### Nginx example
|
||||
|
||||
```nginx
|
||||
# /etc/nginx/sites-available/mosaic
|
||||
|
||||
# Gateway API
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name your-domain.example.com;
|
||||
|
||||
ssl_certificate /etc/ssl/certs/your-domain.crt;
|
||||
ssl_certificate_key /etc/ssl/private/your-domain.key;
|
||||
|
||||
# WebSocket support (for chat.gateway.ts / Socket.IO)
|
||||
location /socket.io/ {
|
||||
proxy_pass http://127.0.0.1:14242;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
# REST + auth
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:14242;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
|
||||
# Web app (optional — serve on a subdomain or a separate server block)
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name app.your-domain.example.com;
|
||||
|
||||
ssl_certificate /etc/ssl/certs/your-domain.crt;
|
||||
ssl_certificate_key /etc/ssl/private/your-domain.key;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Caddy example
|
||||
|
||||
```caddyfile
|
||||
# /etc/caddy/Caddyfile
|
||||
|
||||
your-domain.example.com {
|
||||
reverse_proxy /socket.io/* localhost:14242 {
|
||||
header_up Upgrade {http.upgrade}
|
||||
header_up Connection {http.connection}
|
||||
}
|
||||
reverse_proxy localhost:14242
|
||||
}
|
||||
|
||||
app.your-domain.example.com {
|
||||
reverse_proxy localhost:3000
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Production Considerations
|
||||
|
||||
### systemd Services
|
||||
|
||||
Create a service unit for each process.
|
||||
|
||||
**Gateway** — `/etc/systemd/system/mosaic-gateway.service`:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Mosaic Gateway
|
||||
After=network.target postgresql.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=mosaic
|
||||
WorkingDirectory=/opt/mosaic
|
||||
EnvironmentFile=/opt/mosaic/.env
|
||||
ExecStart=/usr/bin/node apps/gateway/dist/main.js
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
**Web app** — `/etc/systemd/system/mosaic-web.service`:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Mosaic Web App
|
||||
After=network.target mosaic-gateway.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=mosaic
|
||||
WorkingDirectory=/opt/mosaic/apps/web
|
||||
EnvironmentFile=/opt/mosaic/.env
|
||||
ExecStart=/usr/bin/node .next/standalone/server.js
|
||||
Environment=PORT=3000
|
||||
Environment=HOSTNAME=127.0.0.1
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
Enable and start:
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now mosaic-gateway mosaic-web
|
||||
```
|
||||
|
||||
### Log Management
|
||||
|
||||
Gateway and web app logs go to systemd journal by default. View with:
|
||||
|
||||
```bash
|
||||
journalctl -u mosaic-gateway -f
|
||||
journalctl -u mosaic-web -f
|
||||
```
|
||||
|
||||
Rotate logs by configuring `journald` in `/etc/systemd/journald.conf`:
|
||||
|
||||
```ini
|
||||
SystemMaxUse=500M
|
||||
MaxRetentionSec=30day
|
||||
```
|
||||
|
||||
### Security Checklist
|
||||
|
||||
- Set `BETTER_AUTH_SECRET` to a cryptographically random value (`openssl rand -base64 32`).
|
||||
- Restrict `GATEWAY_CORS_ORIGIN` to your exact frontend origin — do not use `*`.
|
||||
- Run services as a dedicated non-root system user (e.g., `mosaic`).
|
||||
- Firewall: only expose ports 80/443 externally; keep 14242 and 3000 bound to `127.0.0.1`.
|
||||
- Set `AGENT_FILE_SANDBOX_DIR` to a directory outside the application root to prevent agent tools from accessing source code.
|
||||
- If using `AGENT_USER_TOOLS`, enumerate only the tools non-admin users need.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Gateway fails to start — "BETTER_AUTH_SECRET is required"
|
||||
|
||||
`BETTER_AUTH_SECRET` is missing or empty. Set it in `.env` and restart.
|
||||
|
||||
### `DATABASE_URL` connection refused
|
||||
|
||||
Verify PostgreSQL is running and the port matches. The Docker Compose default is `5433`; bare-metal typically uses `5432`.
|
||||
|
||||
```bash
|
||||
psql "$DATABASE_URL" -c '\conninfo'
|
||||
```
|
||||
|
||||
### pgvector extension missing
|
||||
|
||||
```sql
|
||||
\c mosaic
|
||||
CREATE EXTENSION IF NOT EXISTS vector;
|
||||
```
|
||||
|
||||
### Valkey / Redis connection refused
|
||||
|
||||
Check the URL in `VALKEY_URL`. The Docker Compose default is port `6380`.
|
||||
|
||||
```bash
|
||||
redis-cli -u "$VALKEY_URL" ping
|
||||
```
|
||||
|
||||
### WebSocket connections fail in production
|
||||
|
||||
Ensure your reverse proxy forwards the `Upgrade` and `Connection` headers. See the Nginx/Caddy examples above.
|
||||
|
||||
### Ollama models not appearing
|
||||
|
||||
Set `OLLAMA_BASE_URL` to the URL where Ollama is running (e.g., `http://localhost:11434`) and set `OLLAMA_MODELS` to a comma-separated list of model IDs you have pulled.
|
||||
|
||||
```bash
|
||||
ollama pull llama3.2
|
||||
```
|
||||
|
||||
### OTEL traces not appearing in Jaeger
|
||||
|
||||
Verify the collector is reachable at `OTEL_EXPORTER_OTLP_ENDPOINT`. With Docker Compose the default is `http://localhost:4318`. Check `docker compose ps` and `docker compose logs otel-collector`.
|
||||
|
||||
### Summarization / embedding features not working
|
||||
|
||||
These features require `OPENAI_API_KEY` to be set, or you must point `SUMMARIZATION_API_URL` / `EMBEDDING_API_URL` to an OpenAI-compatible endpoint (e.g., a local Ollama instance with an embeddings model).
|
||||
|
||||
@@ -39,7 +39,7 @@ mosaic-mono-v1/
|
||||
│ ├── queue/ # Valkey-backed task queue
|
||||
│ └── types/ # Shared TypeScript types
|
||||
├── docker/ # Dockerfile(s) for containerized deployment
|
||||
├── infra/ # Infrastructure configuration (for example, OTEL collector)
|
||||
├── infra/ # Infra config (OTEL collector, pg-init scripts)
|
||||
├── docker-compose.yml # Local services (Postgres, Valkey, OTEL, Jaeger)
|
||||
└── CLAUDE.md # Project conventions for AI coding agents
|
||||
```
|
||||
@@ -86,54 +86,71 @@ cd mosaic-mono-v1
|
||||
pnpm install
|
||||
```
|
||||
|
||||
### 2. Use the local PGlite tier
|
||||
|
||||
The supported local tier is in-process PGlite and requires no PostgreSQL service. Leave
|
||||
`DATABASE_URL` unset for this route. Its default local configuration uses PGlite and performs no
|
||||
external database probe.
|
||||
|
||||
If a local queue service is useful, start only that non-PostgreSQL service:
|
||||
### 2. Start Infrastructure Services
|
||||
|
||||
```bash
|
||||
docker compose up -d valkey
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Do not use the current Compose PostgreSQL service: it mounts legacy `infra/pg-init` SQL and is
|
||||
not qualified for KBN-101. Start OTEL Collector or Jaeger individually only when needed and
|
||||
without starting PostgreSQL.
|
||||
This starts:
|
||||
|
||||
### 3. Gateway/Web local process (held)
|
||||
| Service | Port | Description |
|
||||
| ------------------------ | -------------- | -------------------- |
|
||||
| PostgreSQL 17 + pgvector | `5433` (host) | Primary database |
|
||||
| Valkey 8 | `6380` (host) | Queue and cache |
|
||||
| OpenTelemetry Collector | `4317`, `4318` | OTEL gRPC and HTTP |
|
||||
| Jaeger | `16686` | Distributed trace UI |
|
||||
|
||||
Do not start the current Gateway or web process as a local PGlite route. Gateway first loads the
|
||||
daemon configuration and then project environment files without a tier guard; a pre-existing
|
||||
`DATABASE_URL` can select PostgreSQL, where current startup still reaches runtime DDL/migrations.
|
||||
Creating a root `.env` that omits `DATABASE_URL` does not make this safe, so neither a local
|
||||
credential file nor a web environment file is a current developer procedure.
|
||||
### 3. Configure Environment
|
||||
|
||||
PGlite remains the supported in-process data-layer implementation, and the optional Valkey command
|
||||
above remains safe because it does not start PostgreSQL. A safe Gateway/Web local procedure is held
|
||||
until KBN-101-02 rejects a daemon, inherited, root, or app-local PostgreSQL DSN and any non-local
|
||||
tier before connection or DDL; KBN-101-05 then supplies the production renderer/Vault process-exec
|
||||
or `LoadCredential` boundary.
|
||||
Create a `.env` file in the monorepo root:
|
||||
|
||||
### Held future procedure
|
||||
```env
|
||||
# Database (matches docker-compose defaults)
|
||||
DATABASE_URL=postgresql://mosaic:mosaic@localhost:5433/mosaic
|
||||
|
||||
PostgreSQL local and federated deployment are held until KBN-101-00 (external bootstrap),
|
||||
KBN-101-03 (runner), and KBN-101-05 (renderer-backed deployment) land. The following is the
|
||||
**held, non-operative future activation order with no current command authority**:
|
||||
# Auth (required — generate a random 32+ char string)
|
||||
BETTER_AUTH_SECRET=change-me-to-a-random-secret
|
||||
|
||||
external bootstrap → TLS/roles → `mosaic-db-migrator --run` →
|
||||
`mosaic-db-migrator --verify` → Gateway/Compose readiness.
|
||||
# Gateway
|
||||
GATEWAY_PORT=14242
|
||||
GATEWAY_CORS_ORIGIN=http://localhost:3000
|
||||
|
||||
Neither current Compose nor this development guide authorizes PostgreSQL initialization SQL,
|
||||
manual DDL, or a pre-runner start.
|
||||
# Web
|
||||
NEXT_PUBLIC_GATEWAY_URL=http://localhost:14242
|
||||
|
||||
### 5. Gateway/Web start (held)
|
||||
# Optional: Ollama
|
||||
OLLAMA_BASE_URL=http://localhost:11434
|
||||
OLLAMA_MODELS=llama3.2
|
||||
```
|
||||
|
||||
No Gateway/Web start command is currently authorized for the local PGlite route. Do not use root
|
||||
`pnpm dev` as a workaround: it additionally starts configured integrations and cannot establish the
|
||||
required local-tier/DSN isolation. Resume this section only after KBN-101-02 provides its
|
||||
fail-closed local-startup evidence.
|
||||
The gateway loads `.env` from the monorepo root via `dotenv` at startup
|
||||
(`apps/gateway/src/main.ts`).
|
||||
|
||||
### 4. Push the Database Schema
|
||||
|
||||
```bash
|
||||
pnpm --filter @mosaicstack/db db:push
|
||||
```
|
||||
|
||||
This applies the Drizzle schema directly to the database (development only; use
|
||||
migrations in production).
|
||||
|
||||
### 5. Start the Gateway
|
||||
|
||||
```bash
|
||||
pnpm --filter @mosaicstack/gateway exec tsx src/main.ts
|
||||
```
|
||||
|
||||
The gateway starts on port `14242` by default.
|
||||
|
||||
### 6. Start the Web App
|
||||
|
||||
```bash
|
||||
pnpm --filter @mosaicstack/web dev
|
||||
```
|
||||
|
||||
The web app starts on port `3000` by default.
|
||||
|
||||
---
|
||||
|
||||
@@ -283,13 +300,26 @@ Implement a standard MCP server that exposes tools via the streamable HTTP
|
||||
transport or SSE transport. The server must accept connections at a `/mcp`
|
||||
endpoint.
|
||||
|
||||
### 2. Gateway MCP configuration (held)
|
||||
### 2. Configure `MCP_SERVERS`
|
||||
|
||||
Do not configure MCP endpoint credentials, write them to a local environment file, or restart the
|
||||
Gateway from this guide. Gateway/Web startup is held until KBN-101-02 supplies fail-closed
|
||||
local-tier/DSN isolation and KBN-101-05 supplies the renderer/Vault process-exec or
|
||||
`LoadCredential` secret-consumer interface. The future authenticated MCP route requires verified
|
||||
HTTPS and certificate validation; plaintext bearer-token examples are forbidden.
|
||||
In your `.env`:
|
||||
|
||||
```env
|
||||
MCP_SERVERS='[{"name":"my-server","url":"http://localhost:3001/mcp"}]'
|
||||
```
|
||||
|
||||
With authentication:
|
||||
|
||||
```env
|
||||
MCP_SERVERS='[{"name":"secure-server","url":"http://my-server/mcp","headers":{"Authorization":"Bearer token"}}]'
|
||||
```
|
||||
|
||||
### 3. Restart the Gateway
|
||||
|
||||
On startup, `McpClientService` (`apps/gateway/src/mcp-client/mcp-client.service.ts`)
|
||||
connects to each configured server, calls `tools/list`, and bridges the results
|
||||
to Pi SDK `ToolDefinition` format. These tools become available in all new agent
|
||||
sessions.
|
||||
|
||||
### Tool Naming
|
||||
|
||||
@@ -325,31 +355,42 @@ The schema lives in a single file:
|
||||
|
||||
The `insights` table uses a `vector(1536)` column (pgvector) for semantic search.
|
||||
|
||||
### PostgreSQL schema work (held)
|
||||
### Development: Push Schema
|
||||
|
||||
Do not prepare or run a PostgreSQL target from this branch. The sole runner, bootstrap, and
|
||||
renderer are future KBN-101 artifacts, not current commands. When KBN-101-00/-03/-05 land, the
|
||||
owned activation documentation will require external bootstrap → TLS/roles → runner `--run` →
|
||||
runner `--verify` → Gateway/Compose readiness.
|
||||
Apply schema changes directly to the dev database (no migration files created):
|
||||
|
||||
### Generating migration artifacts
|
||||
```bash
|
||||
pnpm --filter @mosaicstack/db db:push
|
||||
```
|
||||
|
||||
`pnpm --filter @mosaicstack/db db:generate` is an offline artifact-generation command. It does
|
||||
not authorize connecting to or initializing PostgreSQL. A future reviewed PostgreSQL procedure
|
||||
will determine when its output is applied.
|
||||
### Generating Migrations
|
||||
|
||||
For production-safe, versioned changes:
|
||||
|
||||
```bash
|
||||
pnpm --filter @mosaicstack/db db:generate
|
||||
```
|
||||
|
||||
This creates a new SQL migration file in `packages/db/drizzle/`.
|
||||
|
||||
### Running Migrations
|
||||
|
||||
```bash
|
||||
pnpm --filter @mosaicstack/db db:migrate
|
||||
```
|
||||
|
||||
### Drizzle Config
|
||||
|
||||
Config is at `packages/db/drizzle.config.ts`. The schema file path and output directory are
|
||||
defined there.
|
||||
Config is at `packages/db/drizzle.config.ts`. The schema file path and output
|
||||
directory are defined there.
|
||||
|
||||
### Adding a New Table
|
||||
|
||||
1. Add the table definition to `packages/db/src/schema.ts`.
|
||||
2. Export it from `packages/db/src/index.ts`.
|
||||
3. Generate the offline artifact with `pnpm --filter @mosaicstack/db db:generate`.
|
||||
4. Do not apply it to PostgreSQL until the future KBN-101 activation artifacts and their owned
|
||||
procedure are available. Direct schema push is not a production-like workflow.
|
||||
3. Run `pnpm --filter @mosaicstack/db db:push` (dev) or
|
||||
`pnpm --filter @mosaicstack/db db:generate && pnpm --filter @mosaicstack/db db:migrate`
|
||||
(production).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,98 +1,147 @@
|
||||
# Migrating to the Federated Tier
|
||||
|
||||
> **KBN-101-07 ownership:** This active documentation is a **non-operative KBN-101
|
||||
> contract** with no current command authority until KBN-101-00, KBN-101-02, KBN-101-03, KBN-101-05, and KBN-101-06 land and
|
||||
> KBN-101-08 activates an exact reviewed release. The commands below describe the produced interface only. Do not run them on the
|
||||
> current branch or replace them with direct PostgreSQL, raw SQL, legacy storage migration, or
|
||||
> credential-on-argv procedures.
|
||||
Step-by-step guide to migrate from `local` (PGlite) or `standalone` (PostgreSQL without pgvector) to `federated` (PostgreSQL 17 + pgvector + Valkey).
|
||||
|
||||
## Held future procedure
|
||||
## When to migrate
|
||||
|
||||
This section is non-operative and grants no current command authority until KBN-101-00, KBN-101-03, and KBN-101-05 land.
|
||||
Migrate to federated tier when:
|
||||
|
||||
The deployment control plane executes the complete held future procedure, in order: external bootstrap → TLS/roles → `mosaic-db-migrator --run` → `mosaic-db-migrator --verify` → Gateway/Compose readiness. The
|
||||
runner is the only attestation producer after its verified TLS, identity, manifest, and schema
|
||||
checks. A data importer is never a schema bootstrap, extension installer, repair command, or DDL
|
||||
consumer.
|
||||
- Scaling from single-user to multi-user deployments
|
||||
- Adding vector embeddings or RAG features
|
||||
- Running Mosaic across multiple hosts
|
||||
- Requires distributed task queueing and caching
|
||||
- Moving to production with high availability
|
||||
|
||||
## Target material contract
|
||||
## Prerequisites
|
||||
|
||||
KBN-101-05 obtains the target URL from Vault KV-v2
|
||||
`secret-{env}/mosaic-stack/database/importer`, key `url`, and reads its authenticated version from
|
||||
the same successful response `data.metadata.version`. A hash or DSN byte sequence is not a
|
||||
provider version. The renderer treats URL bytes and provider version as one generation, writes a
|
||||
temporary generation directory with fsync plus atomic rename, and creates separate immutable
|
||||
consumer mounts. Swarm uses distinct versioned secret/config references. A deployment cannot mix
|
||||
generations.
|
||||
- Federated stack running and healthy (see [Federated Tier Setup](../federation/SETUP.md))
|
||||
- Source database accessible and empty target database at the federated URL
|
||||
- Backup of source database (recommended before any migration)
|
||||
|
||||
| Consumer | Permitted material |
|
||||
| --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Migrator-attestation producer (`10003:10003`) | Its own migration URL/CA; read-only `/run/secrets/mosaic-migrate-target-url` and `/run/secrets/mosaic-migrate-target-version`, each `0400`, solely to bind; producer-only attestation output at `/run/mosaic-attestations-producer/migrate-target.v1.json`; root-wrapper-only signing key. It never connects with, uses, exports, logs, or forwards the importer URL/version. |
|
||||
| Privileged deployment handoff controller | After runner success and before importer creation, it receives only root-owned non-secret expected provider-version/URL-SHA-256/generation descriptor and pinned public verifier key—not URL bytes or private key. It safe-opens/verifies descriptor and producer artifact, copies exact bytes to a new importer-only mount with fsync/atomic rename, sets `10002:10002` `0400`, seals it read-only, and refuses importer start on any partial/wrong-generation/wrong-owner/mode result. |
|
||||
| Importer (`10002:10002`) | Its own immutable `0400` copies at the same URL/version paths; CA at exact `DATABASE_TLS_CA_CERT_PATH=/run/secrets/mosaic-db-ca.crt`; pinned Ed25519 public key; read-only `/run/mosaic-attestations/migrate-target.v1.json` supplied only by the sealed handoff. |
|
||||
| Gateway/runtime/unrelated container | No importer URL/version, importer artifact, attestation private key, or unrelated CA mount. |
|
||||
## Dry-run first
|
||||
|
||||
The migrator and importer safe-open URL, provider-version, attestation, and public-key files only
|
||||
with `O_RDONLY|O_CLOEXEC|O_NOFOLLOW`; they validate from the opened fd that the file is regular,
|
||||
has its expected owner/mode and link count one. The migrator digests only that URL fd for binding,
|
||||
then zeroizes/closes it. The importer reads URL bytes once into protected memory, validates the
|
||||
signed binding and exact CA before connecting from those same bytes, then zeroizes/closes every
|
||||
fd. It neither logs nor exposes a URL/version/attestation/key oracle.
|
||||
|
||||
## Produced command interface
|
||||
|
||||
After activation and only after approved target preparation, the future interface is:
|
||||
Always run a dry-run to validate the migration:
|
||||
|
||||
```bash
|
||||
# Deployment control plane has already completed the held runner procedure above.
|
||||
mosaic storage migrate-tier --to federated \
|
||||
--target-url-file /run/secrets/mosaic-migrate-target-url \
|
||||
--target-attestation-file /run/mosaic-attestations/migrate-target.v1.json \
|
||||
--target-url postgresql://mosaic:mosaic@localhost:5433/mosaic \
|
||||
--dry-run
|
||||
```
|
||||
|
||||
The provider-version file is fixed deployment material, not argv. This connecting dry-run consumes its nonce; before an actual copy, the deployment control plane must provide fresh runner verification and a new sealed handoff. The runner uses its migration
|
||||
identity; the importer connects only as non-DDL `mosaic_data_importer` and only after all
|
||||
pre-connect validation. After verified TLS and before DML it compares PostgreSQL system ID,
|
||||
database OID, `current_user`, CA/SPKI, and manifest/schema fingerprints to the artifact.
|
||||
Expected output (partial example):
|
||||
|
||||
## Required refusals and evidence
|
||||
```
|
||||
[migrate-tier] Analyzing source tier: pglite
|
||||
[migrate-tier] Analyzing target tier: federated
|
||||
[migrate-tier] Precondition: target is empty ✓
|
||||
users: 5 rows
|
||||
teams: 2 rows
|
||||
conversations: 12 rows
|
||||
messages: 187 rows
|
||||
... (all tables listed)
|
||||
[migrate-tier] NOTE: Source tier has no pgvector support. insights.embedding will be NULL on all migrated rows.
|
||||
[migrate-tier] DRY-RUN COMPLETE (no data written). 206 total rows would be migrated.
|
||||
```
|
||||
|
||||
KBN-101-02/-03/-05/-06 must prove, with stable sanitized errors, that no target connection occurs
|
||||
for missing/unsafe URL/version/attestation/public-key files; symlink, hardlink, owner, mode, or
|
||||
TOCTOU violations; mixed URL/version generations; missing/wrong CA mount; stale/replayed/tampered
|
||||
or revoked-key artifacts; provider rotation/revocation; wrong TLS/server/database/role/manifest
|
||||
binding; raw `--target-url`; `DATABASE_URL` fallback; runtime/owner identity; consumer leakage;
|
||||
or any DDL attempt. Post-connect identity mismatch closes with zero DML/DDL. Tests also prove no
|
||||
forwarding, child environment, logging, or error oracle leaks URL/version/key/artifact contents.
|
||||
Review the output. If it shows an error (e.g., target not empty), address it before proceeding.
|
||||
|
||||
The attestation is credential-free JCS with detached Ed25519 signature and binds issued/expiry,
|
||||
nonce, authenticated provider version, exact URL-fd SHA-256, TLS host/port/database, CA/SPKI,
|
||||
PostgreSQL system ID/database OID, importer role, manifest/schema, and producer identity. Provider
|
||||
version rotation invalidates an old artifact and requires a fresh rendered generation plus runner
|
||||
verification.
|
||||
## Run the migration
|
||||
|
||||
## Actual copy after dry-run
|
||||
|
||||
After reviewed dry-run, obtain the required fresh verification/attestation generation, then use:
|
||||
When ready, run without `--dry-run`:
|
||||
|
||||
```bash
|
||||
# Deployment control plane has supplied fresh runner verification and attestation.
|
||||
mosaic storage migrate-tier --to federated \
|
||||
--target-url-file /run/secrets/mosaic-migrate-target-url \
|
||||
--target-attestation-file /run/mosaic-attestations/migrate-target.v1.json \
|
||||
--target-url postgresql://mosaic:mosaic@localhost:5433/mosaic \
|
||||
--yes
|
||||
```
|
||||
|
||||
The dry-run artifact is terminally replayed and must be rejected; `--yes` bypasses no file,
|
||||
generation, signature, TLS, identity, or DDL control.
|
||||
The `--yes` flag skips the confirmation prompt (required in non-TTY environments like CI).
|
||||
|
||||
## Data boundary and recovery
|
||||
The command will:
|
||||
|
||||
The importer has only an allowlisted mutable-table DML registry. It has no grant for immutable KBN
|
||||
relations, schemas, roles, memberships, extensions, catalogs, or the Drizzle ledger. Source PGlite
|
||||
uses its explicit local directory and does not make a PostgreSQL URL fallback valid.
|
||||
1. Acquire an advisory lock (blocks concurrent invocations)
|
||||
2. Copy data from source to target in dependency order
|
||||
3. Report rows migrated per table
|
||||
4. Display any warnings (e.g., null vector embeddings)
|
||||
|
||||
A failed or ambiguous migration is a control-plane incident: preserve sanitized evidence, retain
|
||||
the approved backup/rollback state, and retry only after independent review. Never inspect,
|
||||
unlock, repair, or initialize the target with ad hoc SQL or copied credentials.
|
||||
## What gets migrated
|
||||
|
||||
All persistent, user-bound data is migrated in dependency order:
|
||||
|
||||
- **users, teams, team_members** — user and team ownership
|
||||
- **accounts** — OAuth provider tokens (durable credentials)
|
||||
- **projects, agents, missions, tasks** — all project and agent definitions
|
||||
- **conversations, messages** — all chat history
|
||||
- **preferences, insights, agent_logs** — preferences and observability
|
||||
- **provider_credentials** — stored API keys and secrets
|
||||
- **tickets, events, skills, routing_rules, appreciations** — auxiliary records
|
||||
|
||||
Full order is defined in code (`MIGRATION_ORDER` in `packages/storage/src/migrate-tier.ts`).
|
||||
|
||||
## What gets skipped and why
|
||||
|
||||
Three tables are intentionally not migrated:
|
||||
|
||||
| Table | Reason |
|
||||
| ----------------- | ----------------------------------------------------------------------------------------------- |
|
||||
| **sessions** | TTL'd auth sessions from the old environment; they will fail JWT verification on the new target |
|
||||
| **verifications** | One-time tokens (email verify, password reset) that have either expired or been consumed |
|
||||
| **admin_tokens** | Hashed tokens bound to the old environment's secret keys; must be re-issued |
|
||||
|
||||
**Note on accounts and provider_credentials:** These durable credentials ARE migrated because they are user-bound and required for resuming agent work on the target environment. After migration to a multi-tenant federated deployment, operators may want to audit or wipe these if users are untrusted or credentials should not be shared.
|
||||
|
||||
## Idempotency and concurrency
|
||||
|
||||
The migration is **idempotent**:
|
||||
|
||||
- Re-running is safe (uses `ON CONFLICT DO UPDATE` internally)
|
||||
- Ideal for retries on transient failures
|
||||
- Concurrent invocations are blocked by a Postgres advisory lock; the second caller will wait
|
||||
|
||||
If a previous run is stuck, check for advisory locks:
|
||||
|
||||
```sql
|
||||
SELECT * FROM pg_locks WHERE locktype='advisory';
|
||||
```
|
||||
|
||||
If you need to force-unlock (dangerous):
|
||||
|
||||
```sql
|
||||
SELECT pg_advisory_unlock(<lock_id>);
|
||||
```
|
||||
|
||||
## Verify the migration
|
||||
|
||||
After migration completes, spot-check the target:
|
||||
|
||||
```bash
|
||||
# Count rows on a few critical tables
|
||||
psql postgresql://mosaic:mosaic@localhost:5433/mosaic -c \
|
||||
"SELECT 'users' as table, COUNT(*) FROM users UNION ALL
|
||||
SELECT 'conversations' as table, COUNT(*) FROM conversations UNION ALL
|
||||
SELECT 'messages' as table, COUNT(*) FROM messages;"
|
||||
```
|
||||
|
||||
Verify a known user or project exists by ID:
|
||||
|
||||
```bash
|
||||
psql postgresql://mosaic:mosaic@localhost:5433/mosaic -c \
|
||||
"SELECT id, email FROM users WHERE email='<your-email>';"
|
||||
```
|
||||
|
||||
Ensure vector embeddings are NULL (if source was PGlite) or populated (if source was postgres + pgvector):
|
||||
|
||||
```bash
|
||||
psql postgresql://mosaic:mosaic@localhost:5433/mosaic -c \
|
||||
"SELECT embedding IS NOT NULL as has_vector FROM insights LIMIT 5;"
|
||||
```
|
||||
|
||||
## Rollback
|
||||
|
||||
There is no in-place rollback. If the migration fails:
|
||||
|
||||
1. Restore the target database from a pre-migration backup
|
||||
2. Investigate the failure logs
|
||||
3. Rerun the migration
|
||||
|
||||
Always test migrations in a staging environment first.
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
# Mos Connector Lease Operations — M1
|
||||
|
||||
## Operational status
|
||||
|
||||
M1 installs the durable schema and gateway policy/adapter boundary. It does **not** activate a connector, expose a lease administration endpoint, or cut over a channel. The default gateway connector-lease policy is deny-all until a later work package supplies an authorized server-side policy and concrete adapter.
|
||||
|
||||
## Events to monitor
|
||||
|
||||
Use correlation IDs to follow `connector_lease_audit_log` events:
|
||||
|
||||
| Event | Meaning |
|
||||
| ---------- | --------------------------------------------------------------------- |
|
||||
| `acquire` | First holder inserted for an unused binding |
|
||||
| `renew` | Current holder heartbeat extended the TTL |
|
||||
| `takeover` | Authorized CAS replaced the holder and incremented epoch |
|
||||
| `release` | Current holder explicitly relinquished authority |
|
||||
| `expiry` | An expired current lease was observed |
|
||||
| `reject` | Policy, CAS, expiry, scope, or fencing validation denied an operation |
|
||||
|
||||
Audit data is metadata-only. Raw grant objects, connector payloads, scopes, tokens, approval references, and credentials must never be added to audit output.
|
||||
|
||||
## Incident checks
|
||||
|
||||
For suspected duplicate/stale connector effects:
|
||||
|
||||
1. Correlate the attempted operation with its `reject`, `takeover`, or `expiry` event.
|
||||
2. Compare the current row's connector ID, lease UUID, epoch, expiry, and release time with the adapter's normalized execution context.
|
||||
3. Treat an old epoch, old lease UUID, expired lease, or released lease as non-authoritative. Do not retry it as the old holder.
|
||||
4. Recovery uses the authorized takeover path with the observed expected epoch. Ordinary acquire is intentionally rejected for expired/released rows.
|
||||
5. If an external effect may already have happened, preserve evidence and do not assume lease fencing provides exactly-once replay safety.
|
||||
|
||||
## Migration and rollback safety
|
||||
|
||||
Migration `0016_salty_morlocks.sql` is additive: it creates two new tables and indexes without modifying existing authorization/session tables. Before rollout, normal database backup and migration verification still apply. Rolling application code back leaves unused additive tables in place; dropping tables is not part of automated rollback because it would destroy lease/audit evidence.
|
||||
|
||||
## Security constraints
|
||||
|
||||
- Tenant comes from authenticated gateway context, never a connector request field.
|
||||
- Logical agent, binding, connector, and scope identifiers use normalized constrained forms.
|
||||
- Takeover requires explicit gateway policy authorization and an expected epoch.
|
||||
- Default defense-in-depth TTL caps are 5 minutes for leases and 30 seconds for grants; policy may enforce stricter limits.
|
||||
- Validation and rejection audit complete before adapter side effects.
|
||||
- Existing authz and exact-action approval controls remain additional required gates; a valid connector lease does not bypass them.
|
||||
@@ -522,14 +522,8 @@ mosaic storage export --bucket agent-artifacts --output ./artifacts.tar.gz
|
||||
# Import data into storage
|
||||
mosaic storage import --bucket agent-artifacts --input ./artifacts.tar.gz
|
||||
|
||||
# Schema migration is unavailable in this release. The current storage wrapper shells
|
||||
# directly to `pnpm --filter @mosaicstack/db db:migrate`; it is legacy N-1,
|
||||
# uncertified, and MUST NOT be invoked pending KBN-101-02/-03/-06/-08 activation.
|
||||
# Future schema migration is non-operative: external bootstrap → TLS/roles → runner
|
||||
# --run → runner --verify → readiness.
|
||||
|
||||
# Tier copy uses only the separately held secure migrate-tier route. Never use a legacy
|
||||
# --from/--to storage-migrate command or pass a credential on argv.
|
||||
# Migrate data between tiers
|
||||
mosaic storage migrate --from hot --to cold --older-than 30d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -1,28 +1,26 @@
|
||||
# Native Kanban/SOT Canon
|
||||
|
||||
**Status:** KCR-001–016 independently cleared; KBN-101 rc.16 current generic storage-wrapper authority remediation awaits independent exact-head re-review under issue [#771](https://git.mosaicstack.dev/mosaicstack/stack/issues/771)
|
||||
**Status:** KCR-001–016 independently cleared; canonical publication is in progress under issue [#751](https://git.mosaicstack.dev/mosaicstack/stack/issues/751)
|
||||
**Date:** 2026-07-14
|
||||
**Implementation hold:** no feature implementation starts until this canon is squash-merged to `main` with terminal-green CI; after merge, every slice remains held until its KBN prerequisite graph is satisfied.
|
||||
|
||||
## Artifacts
|
||||
|
||||
| Artifact | Purpose |
|
||||
| -------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| [Canonical requirements](../requirements/native-kanban-sot.md) | Canonical P0–P3 requirements, all seven ratified decisions, fixed invariants, thin MVP, recovery tiers, non-goals, and per-requirement acceptance criteria |
|
||||
| [`MISSION-MANIFEST.md`](./MISSION-MANIFEST.md) | Mission/authority boundaries, exact role chain, gate model, mandatory SecReview triggers, Certifier final/no-merge rule, and collision-free slice ownership |
|
||||
| [`TASKS.md`](./TASKS.md) | Dependency-ordered, bounded P0–P3 slices with IN/OUT scope, dependencies, shared contracts, file ownership, evidence, and USC coder2/3/4/5 parallelization |
|
||||
| [`KBN-101-DB-ROLE-SPLIT.md`](./KBN-101-DB-ROLE-SPLIT.md) | rc.16 direct-Drizzle current storage-wrapper hold: legacy N-1/uncertified/non-operative pending -02/-03/-06/-08; exact README commented/user-guide executable forms fail before masking and source-consistency rejects runner-delegation copy; held future bootstrap → TLS/roles → run → verify → readiness; plus prior production boundary, pgvector owner, attestation, inventory, manifests, DDL classifier, TLS/bootstrap, activation, and certification contract; foundation prerequisite of KBN-100 and real-role gate before KBN-105 |
|
||||
| [`SHARED-CONTRACT.md`](./SHARED-CONTRACT.md) | Remediated v1 integration contract: proof authority, exact failures/routes/DTOs/MCP ownership, concrete current-main field migration map, relational invariants, Coordinator split, recovery delivery |
|
||||
| [`contracts/kanban-schema.v1.ts`](./contracts/kanban-schema.v1.ts) | Drizzle target declarations including exact owner/principal membership, project congruence, tags/archive, proposals, persisted assignments, monotonic fences, durable retry, immutable evidence/audit |
|
||||
| [`contracts/mechanical-coordinator.v1.ts`](./contracts/mechanical-coordinator.v1.ts) | Pure snapshot decision engine separated from persistence/service adapter; ID-bound approvals, bigint-safe fences, durable retry/quarantine, artifact-backed checkpoints, exact failures |
|
||||
| [`contracts/health-state.v1.ts`](./contracts/health-state.v1.ts) | Discriminated public health, separate branded transaction-local write proof, and non-overlapping denial/transport/version-conflict mappings |
|
||||
| [`contracts/recovery-posture.v1.ts`](./contracts/recovery-posture.v1.ts) | Provider-neutral shape schema plus normative runtime refinement, cross-field constraints, and Lite/Standard/High-assurance defaults |
|
||||
| [`tsconfig.json`](./tsconfig.json) | Strict no-emit project scope for linting and compiling the four frozen TypeScript contracts against the current Stack Drizzle declarations |
|
||||
| [`DOCUMENTATION-CHECKLIST.md`](./DOCUMENTATION-CHECKLIST.md) | Publication documentation gate and implementation-slice deferrals |
|
||||
| [KBN-101 exact-head security review](../reports/native-kanban-sot/kbn-101-contract-security-review-82ce325.md) | Historical `da742ca` REQUEST CHANGES report retained as prior closure evidence; rc.16 awaits independent exact-head re-review after closing the current generic storage-wrapper authority HIGH finding |
|
||||
| [Initial independent review](../reports/native-kanban-sot/canon-initial-review-no-go.md) | KCR-001–016 findings that blocked the first draft |
|
||||
| [Final independent re-review](../reports/native-kanban-sot/canon-final-rereview-go.md) | Closure matrix, reproducible validation evidence, and GO verdict |
|
||||
| [Ultron final gate](../reports/native-kanban-sot/ultron-final-go.md) | Final requirements, authority, schema, migration, recovery, decomposition, and evidence review GO |
|
||||
| Artifact | Purpose |
|
||||
| ---------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| [Canonical requirements](../requirements/native-kanban-sot.md) | Canonical P0–P3 requirements, all seven ratified decisions, fixed invariants, thin MVP, recovery tiers, non-goals, and per-requirement acceptance criteria |
|
||||
| [`MISSION-MANIFEST.md`](./MISSION-MANIFEST.md) | Mission/authority boundaries, exact role chain, gate model, mandatory SecReview triggers, Certifier final/no-merge rule, and collision-free slice ownership |
|
||||
| [`TASKS.md`](./TASKS.md) | Dependency-ordered, bounded P0–P3 slices with IN/OUT scope, dependencies, shared contracts, file ownership, evidence, and USC coder2/3/4/5 parallelization |
|
||||
| [`SHARED-CONTRACT.md`](./SHARED-CONTRACT.md) | Remediated v1 integration contract: proof authority, exact failures/routes/DTOs/MCP ownership, concrete current-main field migration map, relational invariants, Coordinator split, recovery delivery |
|
||||
| [`contracts/kanban-schema.v1.ts`](./contracts/kanban-schema.v1.ts) | Drizzle target declarations including exact owner/principal membership, project congruence, tags/archive, proposals, persisted assignments, monotonic fences, durable retry, immutable evidence/audit |
|
||||
| [`contracts/mechanical-coordinator.v1.ts`](./contracts/mechanical-coordinator.v1.ts) | Pure snapshot decision engine separated from persistence/service adapter; ID-bound approvals, bigint-safe fences, durable retry/quarantine, artifact-backed checkpoints, exact failures |
|
||||
| [`contracts/health-state.v1.ts`](./contracts/health-state.v1.ts) | Discriminated public health, separate branded transaction-local write proof, and non-overlapping denial/transport/version-conflict mappings |
|
||||
| [`contracts/recovery-posture.v1.ts`](./contracts/recovery-posture.v1.ts) | Provider-neutral shape schema plus normative runtime refinement, cross-field constraints, and Lite/Standard/High-assurance defaults |
|
||||
| [`tsconfig.json`](./tsconfig.json) | Strict no-emit project scope for linting and compiling the four frozen TypeScript contracts against the current Stack Drizzle declarations |
|
||||
| [`DOCUMENTATION-CHECKLIST.md`](./DOCUMENTATION-CHECKLIST.md) | Publication documentation gate and implementation-slice deferrals |
|
||||
| [Initial independent review](../reports/native-kanban-sot/canon-initial-review-no-go.md) | KCR-001–016 findings that blocked the first draft |
|
||||
| [Final independent re-review](../reports/native-kanban-sot/canon-final-rereview-go.md) | Closure matrix, reproducible validation evidence, and GO verdict |
|
||||
| [Ultron final gate](../reports/native-kanban-sot/ultron-final-go.md) | Final requirements, authority, schema, migration, recovery, decomposition, and evidence review GO |
|
||||
|
||||
## Recommended USC lane partition
|
||||
|
||||
@@ -34,7 +32,7 @@
|
||||
| **coder5** | Web | Tasks/Projects Kanban/List/detail and later Coordinator/migration-review UI |
|
||||
| **Mos** | Serialized integration | Canon publication, frozen-contract changes, shared-root/exports, integration gates, merge authority |
|
||||
|
||||
The safe order is KBN-010 → KBN-101 foundation → KBN-100 → KBN-101 deployed-role immutable-operation certificate → KBN-105, then coder3 Gateway/MCP server, coder4 CLI/projection, coder5 web, and coder2 recovery can proceed on disjoint files. KBN-100 is blocked on the KBN-101 foundation; real deployed-role certification—not synthetic test roles—is required before KBN-105. coder4 then runs pure Coordinator → importer → cutover tooling serially. No two active slices edit the same files.
|
||||
The safe order is KBN-010 → KBN-100 → KBN-105, then coder3 Gateway/MCP server, coder4 CLI/projection, coder5 web, and coder2 recovery can proceed on disjoint files. coder4 then runs pure Coordinator → importer → cutover tooling serially. No two active slices edit the same files.
|
||||
|
||||
## Recovery defaults
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,95 +1,14 @@
|
||||
# Native Kanban/SOT — Remediated Shared Contract v1
|
||||
|
||||
**Status:** CONTROL-PLANE rc.16 KBN-101 current generic storage-wrapper authority remediation complete; awaiting independent exact-head re-review. Prior KCR-001–016 and rc.4 SI-001 decisions retained; KBN-101 foundation certification precedes KBN-100 and real immutable-operation certification precedes KBN-105
|
||||
**Version:** 1.0.0-rc.16
|
||||
**Date:** 2026-07-15
|
||||
**Status:** CONTROL-PLANE SI-001 AMENDMENT AUTHORIZED; prior KCR-001–016 independent-review GO retained; rc.4 requires independent schema/SecReview before KBN-100
|
||||
**Version:** 1.0.0-rc.4
|
||||
**Date:** 2026-07-14
|
||||
**Change authority:** Mosaic control plane/Jason only
|
||||
**SI-001 amendment authority:** `web1:mosaic-100` control-plane decision under issue #753
|
||||
|
||||
## Amendment record
|
||||
|
||||
### 1.0.0-rc.16 — Current generic storage-wrapper authority closure
|
||||
|
||||
- **Current-source truth:** `packages/storage/src/cli.ts` currently shells `storage migrate --run` directly to `pnpm --filter @mosaicstack/db db:migrate` through `execSync`; no `mosaic-db-migrator` executable exists. README and user-guide command guidance therefore remove that command and any runner-delegation claim. The current wrapper is legacy N-1, uncertified, non-operative, and MUST NOT be invoked pending KBN-101-02/-03/-06/-08 activation.
|
||||
- **Future-only boundary:** future schema migration remains non-operative and follows external bootstrap → TLS/roles → runner `--run` → runner `--verify` → readiness; tier copy uses only the separately held secure migrate-tier route.
|
||||
- **Unmaskable semantic/source-consistency evidence:** before inventory, ownership, or status masking, -06 fails the exact former README commented code-fence generic-wrapper form and exact user-guide executable generic-wrapper form. Its source-consistency test proves the direct-Drizzle `execSync` target and absent runner bin, so any documentation describing current wrapper delegation to the runner fails.
|
||||
- **Non-effect:** prior runner, legacy-CI, Compose, production-secret, attestation, pgvector, manifest, lock, TLS, activation, and serial-gate closures remain unchanged.
|
||||
|
||||
### 1.0.0-rc.15 — Held runner and legacy-CI authority closure
|
||||
|
||||
- **Held runner only:** Current operator documents cannot advertise `mosaic-db-migrator --run|--verify` as executable. The sole passing future form is one `Held future procedure` Markdown section, bounded through its next equal-or-higher heading, that explicitly says non-operative/no-current-command-authority, names KBN-101-00/-03/-05, and preserves external bootstrap → TLS/roles → `mosaic-db-migrator --run` → `mosaic-db-migrator --verify` → Gateway/Compose readiness. Any runner hit outside that section fails before inventory/ownership/status masking.
|
||||
- **PGlite/current-CI boundary:** Fleet backlog current behavior is PGlite-only; PostgreSQL CLI/runner authority remains held until activation. README classifies the checked-in direct `db:migrate` CI job as active legacy N-1, uncertified, non-authorizing as an operator route, and pending KBN-101-06 removal; it is a known direct-DDL exception against an isolated disposable CI database, not approved ordinary behavior. The -06 fixture asserts every required status term and rejects ordinary-authority presentation.
|
||||
- **Non-effect:** prior Compose, production-secret, attestation, pgvector, manifest, lock, TLS, activation, and serial-gate closures remain unchanged.
|
||||
|
||||
### 1.0.0-rc.14 — Current Compose and production-secret route closure
|
||||
|
||||
- **Current developer boundary:** `README.md` and `docs/guides/dev-guide.md` permit only in-process PGlite data-layer work and explicitly selected non-PostgreSQL Compose services. Gateway/Web local start is held because the current unguarded loader can inherit a daemon/project PostgreSQL DSN and reach runtime DDL; KBN-101-02 must reject it before connection. The current PostgreSQL Compose mount is legacy/unqualified; PostgreSQL and federated activation are held until KBN-101-00/-03/-05 and then follow external bootstrap → TLS/roles → runner `--run` → `--verify` → Gateway/Compose readiness.
|
||||
- **Production boundary:** `docs/guides/deployment.md` is non-operative until the KBN-101-05 renderer-backed process-exec or `LoadCredential` interface exists. It contains no active production environment-file, monorepo auto-load, credential export/argv, or secret-activation lifecycle route; future units must preserve generation-pinned Vault consumer isolation.
|
||||
- **Unmaskable semantic negatives:** -06 fails the exact former README/dev/deployment Compose-first sequences and every production `.env`, `EnvironmentFile=`, credential export/argv, or restart-as-secret-activation fixture before owned/status/normative classification. The held PGlite/non-PostgreSQL route and future ordered activation are the only passing fixtures.
|
||||
|
||||
### 1.0.0-rc.13 — Federation-MILESTONES indirect-startup closure
|
||||
|
||||
- **Complete operator inventory:** `docs/federation/MILESTONES.md` is exclusively KBN-101-07 and an exact KBN-101-06 `operator-document` `status-only` record. Its former `pgvector extension installed + verified on startup` wording is superseded and forbidden; it authorizes no current DDL, Compose/init, or runtime/startup path.
|
||||
- **Unmaskable semantic negative:** before inventory disposition, the scanner fixture proves that exact former wording fails. The only passing status-only sequence is external bootstrap → TLS/roles → `mosaic-db-migrator --run` → `mosaic-db-migrator --verify` → Gateway readiness.
|
||||
|
||||
### 1.0.0-rc.12 — Deployable importer generation and indirect-DDL-route closure
|
||||
|
||||
- **Authenticated generation:** KBN-101-05 owns one canonical Vault KV-v2 importer record, `secret-{env}/mosaic-stack/database/importer` key `url`, with its version taken only from the same successful `data.metadata.version` response. Value plus provider version are one generation, never inferred from DSN bytes. The renderer creates separate immutable `0400` URL/version copies for migrator `10003:10003` binding-only access and importer `10002:10002` access; it uses fsync/atomic generation replacement for Compose and distinct versioned secret/config references for Swarm, so deployment cannot mix generations.
|
||||
- **Bounded consumers:** importer alone receives its URL/version, CA at `DATABASE_TLS_CA_CERT_PATH`, pinned public key, and read-only attestation; migrator receives its own migration URL/CA, the URL/version only for no-connect/no-export binding, attestation output, and the root-wrapper-only private key. Safe fd open/fstat/digest/zeroize/close semantics, a privileged producer-only-to-importer-only attestation handoff controller (verify, exact-byte copy, fsync/atomic rename, `10002:10002` `0400` seal, then importer start), no shared writable file, no logging/oracle, provider rotation/revocation, CA/mount, consumer-isolation, and symlink/hardlink/owner/mode/TOCTOU negatives are mandatory.
|
||||
- **Indirect-DDL closure:** `docs/federation/SETUP.md` is non-operative until KBN-101 activation and documents only external bootstrap → TLS/roles → runner `--run` → `--verify` → Gateway readiness. The -06 scanner performs unsuppressible semantic checks for automatic first-boot/startup extension/schema/migration language, Compose-up-before-runner, and init-script authority; the former SETUP wording fails and the remediated sequence passes.
|
||||
|
||||
### 1.0.0-rc.11 — Target-bound importer attestation and exhaustive operator-route closure
|
||||
|
||||
- **Target-bound proof:** trusted `mosaic-db-migrator --verify` now produces the atomic, credential-free `migrate-target.v1.json` JCS/Ed25519 artifact from a runner-only root-owned signing-key reference; the importer receives only pinned public verification keys and the artifact. Its signed v1 fields bind issued/expiry/nonce, exact secret version and SHA-256 of high-entropy target-file bytes, canonical TLS host/port/database, CA/SPKI, PostgreSQL system identifier/database OID, expected importer role, manifest/schema fingerprints, and producer invocation/build/image/correlation. No DSN, username, password, credential bytes, or signing key enters the artifact, importer, runtime, logs, or output.
|
||||
- **Fail-closed importer:** `mosaic storage migrate-tier` requires both `--target-url-file /run/secrets/mosaic-migrate-target-url` and `--target-attestation-file /run/mosaic-attestations/migrate-target.v1.json`. Before target connection it validates files, signature/key/expiry/replay, secret version/digest, TLS/CA/role/manifest bindings and opens/digests/connects from the same in-memory URL bytes. After verified TLS but before transaction/DML it matches server ID, database OID, `current_user`, CA/SPKI, and manifest/schema; failure distinguishes zero connection from connection/zero-DML and DDL remains impossible. Rotation overlap/revocation, atomic rename, replay cache, secret rotation invalidation, and wrong/substituted/stale/tampered/file-change tests are mandatory.
|
||||
- **Closed documentation surface:** KBN-101-06 inventories every current non-normative scanner hit, including `docs/guides/user-guide.md` and status-only `docs/federation/TASKS.md`; the latter is historical and cannot authorize DDL. The legacy `storage migrate` tier-copy syntax is unavailable. `storage migrate` is schema-wrapper delegation only; secure tier data copy is `migrate-tier`. Exact KBN PRD/contract/shared/task paths may be `normative-contract` scan class but are still scanned and cannot mask executable instructions. The normative detail remains [`KBN-101-DB-ROLE-SPLIT.md`](./KBN-101-DB-ROLE-SPLIT.md).
|
||||
- **Non-effect:** pgvector closure, manifest, lock, role graph, TLS, activation, and KBN-100/KBN-105 serial gates are unchanged.
|
||||
|
||||
### 1.0.0-rc.10 — PostgreSQL-valid untrusted pgvector owner and active migrate-tier closure
|
||||
|
||||
- **Valid extension authority:** PostgreSQL 17 + pgvector 0.8.2 `vector` is untrusted (`trusted` absent; `relocatable=true`), so `mosaic_extension_owner` is exactly `NOLOGIN SUPERUSER`, not `NOSUPERUSER`. It is dedicated solely to `mosaic_extensions`, `vector`, and owner-bearing extension members; `rolcanlogin=false`, `rolsuper=true`, zero members, no runtime credential/Vault secret, and no app-container delivery are catalog and deployment proof. An externally controlled audited bootstrap-superuser session alone `SET ROLE`s for extension CREATE/UPDATE/SET SCHEMA, then `RESET ROLE`; fresh and shadow paths do so, while in-place existing work requires exact pre-existing `extowner`.
|
||||
- **Explicit superuser exception:** `GRANT`/`REVOKE` cannot privilege-limit a superuser. The containment is dedicated identity, no login, no membership, external control plane, audit, independent review, backup/rollback, and maintenance window—not a false least-privilege claim. Runtime, migrator, schema owner, importer, and every service role cannot assume the role or alter/update/drop/change extension membership. Managed targets without this exact role are ineligible unless a versioned provider-owned extension-owner profile is independently approved.
|
||||
- **Active secure data-migration route:** `docs/guides/migrate-tier.md` is exclusively KBN-101-07, is active rather than historical, and specifies runner-prepared/verified PostgreSQL destination plus a dedicated non-DDL importer. KBN-101-02 freezes `--target-url-file /run/secrets/mosaic-migrate-target-url`, never credential argv; raw `--target-url`, `DATABASE_URL` fallback, runtime owner, missing/unsafe file, wrong mode, and DDL all fail before target connection/DDL. KBN-101-06 inventory/matrix records the route and exact secure fields, then tests its finite operator-document closure.
|
||||
- **Non-effect:** manifest, lock, `mosaic` application schema, TLS, activation, and KBN-100/KBN-105 serial gates remain unchanged. The normative detail remains [`KBN-101-DB-ROLE-SPLIT.md`](./KBN-101-DB-ROLE-SPLIT.md).
|
||||
|
||||
### 1.0.0-rc.9 — KBN-101 extension-schema boundary, disjoint manifests, and scanner mechanics
|
||||
|
||||
- **Extension schema owner:** `mosaic_extension_owner`, not `mosaic_schema_owner`, creates and owns `mosaic_extensions`, `vector`, and extension-member objects. The external bootstrap actor `SET ROLE`s for fresh creation or approved-owner relocation, then `RESET ROLE`s; rc.10 replaces the earlier membership wording with the PostgreSQL-valid zero-member superuser exception. Schema owner has only `USAGE` for legacy type resolution—never ownership, `CREATE`, `ALTER`, `DROP`, member change, or default-privilege authority. Runtime, migrator, and schema owner must fail catalog and direct DDL denials; shadow/resume/rollback repeat the owner/default-privilege proof.
|
||||
- **Exclusive delivery DAG:** KBN-101-00…09 now has a complete, nonoverlapping exact file/glob manifest with named tests/evidence. The runner mapping is exactly `"mosaic-db-migrator": "./dist/cli.js"` and image `ENTRYPOINT ["mosaic-db-migrator"]`; `packages/storage/src/{cli,migrate-tier}.ts` belongs only to -02, and -07 is documentation only. -08/-09 own evidence paths only. -00…07 are prepared artifacts; the immutable N-1 image remains live until -08 atomic activation, so no independently deployed intermediate can bypass runtime controls.
|
||||
- **Mechanical classifier:** -06 owns the exact scanner, inventory fixture, command-matrix harness, and CI wiring. Inventory records pin path/class/owner/disposition/allowed tokens/rationale/expiry/review revision; unknown, duplicate-owner, ownerless, missing-path, invalid allowlist, and historical-category masking fail. The architecture plan's operative direct `db:migrate` is replaced by sole-runner guidance rather than hidden under a historical category.
|
||||
- **Non-effect:** manifest v1, lock, `mosaic` application-schema ownership, TLS, activation, KBN-100/KBN-105 serial gates, and all earlier canon decisions remain unchanged. The normative detail remains [`KBN-101-DB-ROLE-SPLIT.md`](./KBN-101-DB-ROLE-SPLIT.md).
|
||||
|
||||
### 1.0.0-rc.8 — KBN-101 finite authority, executable runner, and pgvector-owner remediation
|
||||
|
||||
- **Finite authority closure:** KBN-101-06 classifies every current executable source/script/package bin, operator document, and deploy manifest by exact path; unclassified current hits fail. Byte-immutable historical SQL, PGlite-only routines, negative-test literals, vendored/generated artifacts, and clearly labeled historical reports are exact-path/category reviewed allowlists only. `packages/db/src/index.ts` loses its public `runMigrations` export with a direct-import/compile negative; `docs/fleet/backlog-conventions.md` and `docs/PERFORMANCE.md` lose first-use/direct-Drizzle/Gateway-startup migration instructions and carry runner/readiness route negatives. A token scan is only input to the classifier, never proof of authority.
|
||||
- **Executable exclusive cards:** KBN-101-03 alone publishes `mosaic-db-migrator` from `packages/db/package.json`/`src/cli.ts`, owns `docker/db-migrator.Dockerfile`, and keeps `{runner,config.dto,manifest,identity,tls}` private, with exact `--run|--verify|--help`, env-only input, stable exits, and command tests. KBN-101-00 alone owns `infra/pg-bootstrap/roles.sql`, `infra/pg-bootstrap/extensions.sql`, `infra/pg-bootstrap/README.md`, plus bootstrap tests. KBN-101-05 alone owns `tools/db/render-postgres-secrets.ts`, renderer tests, and Compose/Portainer/Swarm/two-gateway declarations, consuming the versioned bootstrap interface. No card overlaps renderer/bootstrap/deployment ownership.
|
||||
- **Extension-owner transition:** `mosaic_extension_owner` is a dedicated NOLOGIN role whose membership/credentials never reach services; the external bootstrap actor alone may `SET ROLE` during bootstrap. Fresh vector and member objects retain that owner. PostgreSQL has no supported extension-owner alteration: approved-owner existing extension relocation validates `pg_extension.extowner`, members/schema/version and uses tested `ALTER EXTENSION ... SET SCHEMA`; legacy runtime-owned extension fails closed to a controlled shadow database migration with backup, evidence, quiesce/final delta, atomic switch, and read-only rollback window. No catalog mutation, ownership adoption, or `DROP CASCADE` is permitted. Runtime/migrator/schema-owner extension ALTER/DROP/member-update denial is mandatory.
|
||||
- **Non-effect:** manifest v1, lock namespace, role/search-path, relocation/TLS/activation, KBN-100/KBN-105 serial gates, and all retained canon decisions are strengthened, not weakened. The normative detail remains [`KBN-101-DB-ROLE-SPLIT.md`](./KBN-101-DB-ROLE-SPLIT.md).
|
||||
|
||||
### 1.0.0-rc.7 — KBN-101 complete current-path, relocation, and two-gateway remediation
|
||||
|
||||
- **Finite current-path closure:** static inventory and the `DATABASE_URL`-only-before-connect/DDL denial matrix now explicitly include Gateway's former temporary-table pgvector test (runner-prepared persistent read/query-only fixture), `docker/init-db.sql` retirement, `migrate-tier.ts` runner/bootstrap-only guidance, and the active two-gateway harness. The harness is migrated, not retired: `postgres-a/b → mosaic-db-migrator-a/b → gateway-a/b`, each with isolated URL/CA material, verified readiness, SANs, and positive/negative TLS evidence.
|
||||
- **Executable relocation:** KBN-101-03 exclusively owns `schema.ts`, Drizzle snapshots/journal/generated relocation and exact tests. All future application declarations use exported `pgSchema('mosaic')`; immutable historical SQL runs only in trusted legacy `public`. `vector` is fixed in non-writable `mosaic_extensions`, with exact catalog relocatability/version eligibility, explicit type/operator qualification, catalog-class ordering, unknown-object fail-closed behavior, clean/current-public/partial/reverse rollback tests, and an N-1 release order.
|
||||
- **Bound deployment ownership:** `mosaicstack/stack` KBN-101-00/05 owns current Compose, Portainer, two-gateway, bootstrap renderer/templates, UID/GID declarations, and rendered validation. Gateway is fixed to `10001:10001`; PostgreSQL UID/GID is image-inspected and frozen only after digest pinning. Exact secret paths, atomic renderer behavior, Compose/Swarm targets/modes, Gateway/PostgreSQL leaf separation, and two-pair TLS failure evidence are required. Mosaic deployment control plane/Jason is the named activation authority; environment IaC/Vault supplies versioned input only.
|
||||
- **Correct traceability:** REQ-03 maps to role/schema/search-path, REQ-04 to TLS, REQ-05 to post-KBN-100 immutability, REQ-06 to rollout/rollback, and REQ-07 to the KBN-101 → KBN-100 → KBN-101 → KBN-105 sequence. No prior manifest/lock/role/DAG/activation decision is weakened.
|
||||
|
||||
### 1.0.0-rc.6 — KBN-101 closed DDL/TLS/ledger activation remediation
|
||||
|
||||
- **Choice:** `mosaic-db-migrator` is the sole application/CI/test PostgreSQL DDL control plane. Every legacy entrypoint is routed or denied, rejects `DATABASE_URL`-only before connection/DDL, and `db:push` is unavailable outside an allowlisted disposable developer target. The runner holds one `max:1` session with fixed `pg_try_advisory_lock(1297044289,1262636593)` across preflight through release.
|
||||
- **Exact ledger:** manifest v1 canonically serializes journal logical index/tag and SHA-256 of exact shipped migration bytes. It maps each observed ledger hash to one tuple; physical insertion order is non-normative, while missing/unknown/duplicate/ambiguous/corrupt/stale states fail closed. Shipped `0009` bytes remain unchanged; a missing/effects-absent `0009` runs normally, an applied-late hash maps normally, and partial/full effects with missing hash require backup restoration or separately reviewed repair—not manual adoption.
|
||||
- **TLS/search path:** operator/IaC owns CA and server leaf lifecycle, exact compose/Swarm secret mounts, server TLS activation, service-DNS SANs, verified-TLS readiness, transition, CA overlap rotation, and rollback. Runtime/migrator use `verify-full`; PGlite is not PostgreSQL TLS evidence. Application sessions use only `pg_catalog,mosaic`; no URL/config-derived identifier reaches SQL.
|
||||
- **Safe release:** cards 00–07 land prepared but inactive; owner-runtime deployments remain N-1. Mosaic control plane/Jason alone authorizes one atomic TLS/roles → runner → readiness → runtime activation or rollback. No runtime-operator compatibility switch, bypass, plaintext interval, or force-on-red exists; all temporary support is removed before KBN-101-08.
|
||||
- **Non-effect:** role graph, immutable certification after KBN-100, KBN-105 gate, rc.5’s preserved rc.4 SI-001 invariants, and all KCR-001–016 decisions remain unchanged. Exact detail is normative in [`KBN-101-DB-ROLE-SPLIT.md`](./KBN-101-DB-ROLE-SPLIT.md).
|
||||
|
||||
### 1.0.0-rc.5 — KBN-101 role/connection split
|
||||
|
||||
- **Choice:** PostgreSQL `standalone` and `federated` runtime uses `DATABASE_URL` only as a non-owner `mosaic_runtime` login; an explicit migration phase uses `DATABASE_MIGRATION_URL` only as `mosaic_migrator`, which `SET ROLE`s to non-login `mosaic_schema_owner` for DDL. Local PGlite remains an explicit embedded exception.
|
||||
- **No fallback / no startup DDL:** missing migration URL fails the migration phase; it never falls back to runtime URL/default/config. Gateway replicas do not run migrations. An advisory-locked migration phase verifies the exact ordered Drizzle ledger fingerprint before replicas may become ready.
|
||||
- **Privilege model:** non-login `mosaic_platform_database_owner` is outside application paths; `mosaic_schema_owner` owns only application/ledger schemas. `mosaic_runtime` has only `mosaic_runtime_capability`, owns no object/schema, cannot assume owner/migrator, has no TEMPORARY privilege, has only read access to the Drizzle ledger, and must fail startup if effective identity, unsafe attributes, authenticated TLS, search path, schema version, grants, or immutable relation privileges differ from the frozen contract. `task_events`, `artifacts`, `task_checkpoints`, `task_checkpoint_artifacts`, and `approval_decision_artifacts` grant runtime only INSERT/SELECT; KBN-100 retains RESTRICT/no-cascade semantics.
|
||||
- **Non-effect:** rc.4 SI-001 candidate-key/FK order and all KCR-001–016 tenancy, SOT, proposal-audit, approval, fence, recovery, no-cascade, endpoint, and wire invariants are unchanged. This amendment neither creates roles/secrets nor changes production deployment.
|
||||
- **Gate:** KBN-101’s role/schema-boundary foundation certificate, Vault/redaction/rotation, N-1/rollback, and independent security GO are mandatory before KBN-100. After KBN-100 creates the immutable relations, KBN-101 real deployed-role immutable-operation certification plus Ultron GO is mandatory before KBN-105; synthetic test-role success alone is insufficient. Exact implementation detail is normative in [`KBN-101-DB-ROLE-SPLIT.md`](./KBN-101-DB-ROLE-SPLIT.md).
|
||||
|
||||
### 1.0.0-rc.4 — KBN010-SI-001 (preserved)
|
||||
### 1.0.0-rc.4 — KBN010-SI-001
|
||||
|
||||
- **Choice:** add the explicitly named, non-partial unique candidate key `missions_workspace_id_uidx` on `missions(workspace_id, id)` and retain `missions_workspace_project_id_uidx` on `(workspace_id, project_id, id)`.
|
||||
- **Rationale:** mission `id` remains globally unique, while the composite candidate key makes the frozen tenant-safe generic mission relations valid. `artifacts` and `approval_decisions` are polymorphic exactly-one-target records and do not consistently carry `project_id`; widening both children would unnecessarily broaden v1 and its target semantics.
|
||||
@@ -100,7 +19,7 @@
|
||||
|
||||
## 1. Authority
|
||||
|
||||
Concrete contracts are the four `contracts/*.v1.ts` files. PostgreSQL/current-main Drizzle is the sole writable SOT. In PostgreSQL standalone/federated deployments, KBN-101 rc.13 DDL/ledger/TLS/role/attestation/generation separation is a precondition to schema implementation and certification. Public health, Valkey, files, exports, providers, browser state, and outage notes cannot authorize/reconstruct writes. Mechanical Coordinator is non-LLM with no scope/gate/certification/merge authority. Certifier is final independent gate with no merge authority. No feature lane starts until this canon merges and the KBN-010/KBN-105 prerequisites are satisfied.
|
||||
Concrete contracts are the four `contracts/*.v1.ts` files. PostgreSQL/current-main Drizzle is the sole writable SOT. Public health, Valkey, files, exports, providers, browser state, and outage notes cannot authorize/reconstruct writes. Mechanical Coordinator is non-LLM with no scope/gate/certification/merge authority. Certifier is final independent gate with no merge authority. No feature lane starts until this canon merges and the KBN-010/KBN-105 prerequisites are satisfied.
|
||||
|
||||
## 2. Health proof and exact failures
|
||||
|
||||
@@ -185,7 +104,7 @@ The candidate key is intentionally redundant with globally unique `missions.id`,
|
||||
|
||||
### 5.3 New audit/proposal DDL order
|
||||
|
||||
KBN-100 migration DDL may begin only after KBN-101 foundation role/schema-boundary certification. It runs in the explicit migrator/owner phase—not Gateway startup—and its generated Drizzle declaration/snapshot/journal must be mutually consistent. It must execute in this order:
|
||||
KBN-100 migration DDL must execute in this order:
|
||||
|
||||
1. create `task_events` and its unique `(workspace_id, id)` key;
|
||||
2. create `change_proposals` with nullable acceptance-event ID and required submission-event ID;
|
||||
@@ -324,7 +243,7 @@ KBN-115/coder2 owns `packages/config/src/recovery-posture.ts`, tests, and recove
|
||||
|
||||
## 9. Integration, security, and hold
|
||||
|
||||
Required release evidence includes KBN-101 foundation role/schema-boundary and post-KBN-100 real immutable-operation deployed-role certificates (not synthetic roles), empty/prod/partial/rollback/N-1 migration tests; cross-workspace and same-workspace wrong-project negatives; active-membership owners/principals; proposal inertness/normal acceptance; exact failure mapping; concurrent monotonic bigint fences; relational lease/checkpoint/evidence mismatch; immutability privileges/RESTRICT; recovery validation/mechanism evidence; endpoint registry alignment; accessible web journeys; author≠reviewer; mandatory SecReview; final Certifier pass/no merge authority.
|
||||
Required release evidence includes empty/prod/partial/rollback/N-1 migration tests; cross-workspace and same-workspace wrong-project negatives; active-membership owners/principals; proposal inertness/normal acceptance; exact failure mapping; concurrent monotonic bigint fences; relational lease/checkpoint/evidence mismatch; immutability privileges/RESTRICT; recovery validation/mechanism evidence; endpoint registry alignment; accessible web journeys; author≠reviewer; mandatory SecReview; final Certifier pass/no merge authority.
|
||||
|
||||
### 9.1 SI-001 amendment gate and #757 boundary
|
||||
|
||||
|
||||
@@ -43,14 +43,12 @@ Shared roots, package exports/manifests, lockfiles, and generated artifacts are
|
||||
```text
|
||||
KBN-000 canon remediation
|
||||
-> KBN-010 threat/auth/constraint-impact gate (MUST COMPLETE)
|
||||
-> KBN-101 foundation role/schema-boundary certificate (SERIAL)
|
||||
-> KBN-100 schema + concrete N-1 migration implementation
|
||||
├─ KBN-101 post-KBN-100 deployed-role immutable-operation certificate (SERIAL)
|
||||
│ -> KBN-105 exact endpoint/DTO/error/registry freeze (SERIAL)
|
||||
│ ├─ KBN-110 domain + Gateway + MCP server implementation
|
||||
│ ├─ KBN-120 CLI/projection implementation [coder4 first]
|
||||
│ └─ KBN-130 web MVP implementation
|
||||
└─ KBN-115 recovery parser/mechanism slice [coder2 lane-serial]
|
||||
-> KBN-100 schema + concrete N-1 migration implementation
|
||||
├─ KBN-105 exact endpoint/DTO/error/registry freeze (SERIAL)
|
||||
│ ├─ KBN-110 domain + Gateway + MCP server implementation
|
||||
│ ├─ KBN-120 CLI/projection implementation [coder4 first]
|
||||
│ └─ KBN-130 web MVP implementation
|
||||
└─ KBN-115 recovery parser/mechanism slice [coder2 lane-serial]
|
||||
KBN-110 + KBN-120 + KBN-130 + KBN-115
|
||||
-> KBN-140 P1 integration/SIT
|
||||
-> KBN-200 pure decision engine [coder4 after KBN-120]
|
||||
@@ -66,7 +64,7 @@ KBN-310 + KBN-320
|
||||
-> KBN-340 owner-gated cutover/stabilization
|
||||
```
|
||||
|
||||
No consumer implementation begins before KBN-105. No schema work begins before KBN-010 completes and the KBN-101 foundation role/schema-boundary certificate passes; the real immutable-operation certificate follows KBN-100 and blocks KBN-105. The coder4 order is always KBN-120 → KBN-200 → KBN-300 → KBN-320.
|
||||
No consumer implementation begins before KBN-105. No schema work begins before KBN-010 completes. The coder4 order is always KBN-120 → KBN-200 → KBN-300 → KBN-320.
|
||||
|
||||
## 4. P0 — Canon, threat gate, schema, and exact API freeze
|
||||
|
||||
@@ -83,9 +81,9 @@ No consumer implementation begins before KBN-105. No schema work begins before K
|
||||
|
||||
### KBN-010 — Threat, authorization, and constraint-impact gate
|
||||
|
||||
- **Status:** IN PROGRESS — issue [#753](https://git.mosaicstack.dev/mosaicstack/stack/issues/753).
|
||||
- **Owner:** `kbn-coder3`; independent `secrev`.
|
||||
- **Mode:** SERIAL prerequisite of KBN-100.
|
||||
- **Status:** COMPLETE — PR #765 squash-merged as `2e228007`; issue #753 closed; post-merge pipeline #1813 terminal success.
|
||||
- **Owner:** `kbn-coder3`; independent Homelab schema/SecReview and `web1:coder0` Codex-Ultron review.
|
||||
- **Mode:** SERIAL prerequisite of KBN-100; completed.
|
||||
- **Exclusive files:** `docs/native-kanban-sot/KBN-010-THREAT-AUTH-CONSTRAINT-GATE.md` and task scratchpad only.
|
||||
- **IN:** Cross-workspace owners/principals/evidence; active membership; stale/forged health; approval forgery; fence monotonicity; audit retention; proposal target/audit-event forgery; service tokens; DB/Valkey outage.
|
||||
- **OUT:** Runtime/schema edits.
|
||||
@@ -93,27 +91,17 @@ No consumer implementation begins before KBN-105. No schema work begins before K
|
||||
- **Contract surfaces:** schema constraints, health proof, exact errors, command-family authorization.
|
||||
- **Evidence:** signed constraint-impact matrix; no unresolved schema-impact finding; SecReview pass.
|
||||
|
||||
### KBN-101 — PostgreSQL runtime/migration role split and deployed-role certification
|
||||
|
||||
- **Status:** IN PROGRESS — issue [#771](https://git.mosaicstack.dev/mosaicstack/stack/issues/771); rc.16 closes HIGH-1 current generic storage-wrapper authority: README/user-guide remove `storage migrate --run` guidance and false runner delegation; current source is direct-Drizzle, legacy N-1, uncertified, non-operative, and forbidden pending -02/-03/-06/-08 activation. The -06 fixture fails both exact former forms before inventory/status masking and source-consistency rejects current direct-Drizzle wrapper as runner delegation. It awaits independent exact-head re-review; implementation remains held.
|
||||
- **Owner:** Mos integration control plane; independently reviewed by security/Ultron.
|
||||
- **Mode:** SERIAL foundation certificate blocks KBN-100; its post-KBN-100 real immutable-operation certificate blocks KBN-105.
|
||||
- **IN:** Exact `DATABASE_URL` non-owner runtime versus `DATABASE_MIGRATION_URL` owner/migrator connection contract; sole published `mosaic-db-migrator --run|--verify` PostgreSQL DDL path and all legacy/future entrypoint closure; active migrate-tier destination only after runner prepare/verify through exact `--target-url-file /run/secrets/mosaic-migrate-target-url`, paired authenticated provider-version file, and signed `--target-attestation-file /run/mosaic-attestations/migrate-target.v1.json`; runner-only signing key/public-key isolation; canonical Vault KV-v2 target URL/version, generation-pinned renderer, importer CA/public-key/attestation plus privileged sealed producer-to-importer handoff, safe-fd/consumer-isolation/no-log-oracle, TLS/server/database/role/manifest/schema binding, expiry/replay/provider-rotation/TOCTOU/no-DML controls, and dedicated non-DDL importer; finite exact-path scanner/allowlist/active-route review plus unsuppressible automatic-startup/init/Compose-before-runner semantic negatives and every-path before-connect denial matrix; `DATABASE_TLS_CA_CERT_PATH` plus operator/IaC CA/server-key/cert lifecycle, exact service-DNS SANs, Vault/compose/Swarm mount modes, TLS server/bootstrap/rotation/rollback; PGlite exception; fixed two-int advisory lock; manifest-v1 logical-index/tag/exact-byte-SHA-256 ledger reconciliation including safe `0009`; fixed `mosaic` schema and exact `pg_catalog,mosaic` pooled session path; platform/schema/`NOLOGIN SUPERUSER` extension-owner/migrator/importer/runtime roles; approved-owner versus legacy-owner shadow pgvector transition; ownership, zero membership/no runtime secret, TEMP/ledger-read/default privilege and immutable grant proof; N-1 inactive prepared cards then atomic activation/rollback authority; Vault/redaction/observability/operator runbooks; one-card/one-PR implementation DAG.
|
||||
- **OUT:** Production mutation in this planning card; KBN-100 tables/data backfill; application API behavior; KBN-105 route/DTO freeze.
|
||||
- **Depends on:** KBN-010 completed.
|
||||
- **Contract surfaces:** [`KBN-101-DB-ROLE-SPLIT.md`](./KBN-101-DB-ROLE-SPLIT.md); `SHARED-CONTRACT.md` rc.15 amendment.
|
||||
- **Evidence:** foundation: exact `--help|--run|--verify`/exit/argv/import-negative plus DTO entrypoint negatives for every finite classified current DDL/static-bypass path (including `DATABASE_URL`-only, runner fixture, retired init, sanitized current operator guidance, both harness pairs, and `db:push` refusal); active migrate-tier paired URL/version/attestation files, signing/public-key isolation, canonical Vault KV-v2 authenticated version, generation-pinned renderer, importer CA, safe fd/TOCTOU/consumer-isolation/no-log-oracle, atomic JCS/Ed25519, digest/TLS/server/database/role/manifest/schema binding, expiry/replay/provider rotation/revocation, zero-connection versus zero-DML, prepared-target/importer/no-DDL negatives; clean/pre-0009/skipped/applied-late/duplicate/unknown/missing/corrupt/stale/backup plus public-to-`mosaic`/partial/reverse runner proof; fixed-lock contention/crash/readiness/unrelated-key tests; runtime cannot invoke migrations/DDL/TEMP; actual pgvector 0.8.2 control metadata, fresh/approved-owner existing/legacy-owner shadow/partial-resume-rollback/N-1 pgvector evidence with `rolcanlogin=false`, `rolsuper=true`, zero members, external-superuser `SET ROLE`/`RESET ROLE` audit, `pg_extension.extowner`, owner-bearing member/schema/version and runtime/migrator/schema-owner/importer/all-service-role `SET ROLE`/ALTER/DROP/member-update denial; disposable standalone, federated/Swarm, and two-gateway verified-TLS positives plus both-pair CA/SAN/downgrade/key mode/UID-GID/URL-secret consumer-isolation and legacy-drain/`hostssl` zero-plaintext negatives; exclusive bootstrap/renderer/manifest ownership test; catalog relocation/vector-query/operator/Drizzle-only-`mosaic`, role/grant/search-path/pool-reset/identifier checks; N-1/atomic TLS-only rollback/no-force-on-red rehearsal; named Vault/bootstrap-control-plane/CA-overlap/redaction/operator evidence; independent author≠reviewer security GO. Post-KBN-100: real deployed non-owner INSERT/SELECT and UPDATE/DELETE denial for immutable event/artifact/evidence relations plus Ultron GO.
|
||||
|
||||
### KBN-100 — Unified Drizzle schema and concrete N-1 migration
|
||||
|
||||
- **Owner:** **coder2**.
|
||||
- **Status:** IN PROGRESS — issue [#769](https://git.mosaicstack.dev/mosaicstack/stack/issues/769); implementation held until tracking commit and baseline evidence are pushed.
|
||||
- **Owner:** **`web1:kbn-schema-coder2`** author lane; independent DB reviewer, SecReview, and Codex-Ultron required.
|
||||
- **Mode:** SERIAL.
|
||||
- **Exclusive files:** `packages/db/src/schema.ts`, `packages/db/drizzle/**`, DB tests.
|
||||
- **IN:** All frozen tables/joins/enums; workspace/project-congruent constraints; owners/principals; tags/archive; change proposals with both workspace-aware task-event composite FKs and frozen event-before-proposal DDL order; assignment approvals; durable execution/quarantine; monotonic bigint fence; exact checkpoint/evidence joins; RESTRICT/immutability; concrete current-main expand/backfill/switch/contract map.
|
||||
- **OUT:** Repositories, Gateway, Coordinator behavior, UI, importer.
|
||||
- **Depends on:** **KBN-010 completed and KBN-101 foundation role/schema-boundary certificate PASS**. KBN-100 is blocked until both are terminal; it rebases on KBN-101 main, restores generated Drizzle declaration/snapshot/journal consistency, and confines procedural immutable-table grant/trigger/backfill work to its schema ownership. Its new relations are then subject to KBN-101 post-KBN-100 deployed-role certification.
|
||||
- **Depends on:** **KBN-010 completed**.
|
||||
- **Contract surfaces:** `kanban-schema.v1.ts`; SHARED-CONTRACT current-main delta map.
|
||||
- **Evidence:** reviewed SQL; empty/prod-shape/partial-resume/rollback tests; N-1 app safety; legacy columns remain declared; workspace/project mismatch negatives; proposal event-FK missing/foreign-workspace tests; one active lease; monotonic fence; parent-delete RESTRICT; immutability privileges; SecReview.
|
||||
- **Evidence:** TDD RED/GREEN; reviewed generated SQL/journal; real PostgreSQL empty/prod-shape/partial-resume/rollback tests; N-1 startup/read/write safety; legacy columns remain declared; workspace/project mismatch negatives; N100-01..50 including rc.4 candidate-before-FK and two-child foreign-workspace tests; proposal event-FK missing/foreign/unrelated tests; one active lease; monotonic fence; parent-delete RESTRICT; immutability privileges; independent DB review, SecReview, Codex-Ultron, terminal-green PR/main CI.
|
||||
|
||||
### KBN-105 — Exact Gateway/MCP endpoint, DTO, and error freeze
|
||||
|
||||
@@ -122,7 +110,7 @@ No consumer implementation begins before KBN-105. No schema work begins before K
|
||||
- **Exclusive files:** canonical endpoint-registry/DTO contract docs; no implementation.
|
||||
- **IN:** Exact routes and methods from SHARED-CONTRACT §8; request/success/error fields; status codes; pagination/filter/revision envelopes; idempotency/expected-version headers/fields; proposal commands; health proof exclusion from public DTOs; MCP tool-to-route map.
|
||||
- **OUT:** Controller/service/client implementation.
|
||||
- **Depends on:** KBN-100 and KBN-101 post-KBN-100 deployed-role immutable-operation certification PASS.
|
||||
- **Depends on:** KBN-100.
|
||||
- **Contract surfaces:** health/error unions; schema IDs/statuses; Gateway DTO freeze.
|
||||
- **Evidence:** every FE/CLI/MCP call maps 1:1 to a route; 503/502-504/409 non-cross-map fixtures; contract digest published.
|
||||
|
||||
@@ -264,15 +252,13 @@ No consumer implementation begins before KBN-105. No schema work begins before K
|
||||
|
||||
## 8. Consistent USC wave schedule
|
||||
|
||||
| Wave | coder2 | coder3 | coder4 | coder5 |
|
||||
| ---- | ----------------------------------------- | ------------------------------------------------------------------------------------ | ------------------------------ | ------------------------------ |
|
||||
| 0 | Wait | **KBN-010** | Wait | Wait |
|
||||
| 0.5 | Wait | **KBN-101 foundation** Mos-controlled role/connection contract and certificate | Wait | Wait |
|
||||
| 1 | **KBN-100** after KBN-101 foundation PASS | Review bounded schema/grant implementation | Wait | Wait |
|
||||
| 1.5 | Certification support | **KBN-101 post-KBN-100 deployed-role immutable-operation certificate**, then KBN-105 | Wait | Wait |
|
||||
| 2 | **KBN-115** after KBN-100 | **KBN-105** exact freeze, then KBN-110 | **KBN-120** only after KBN-105 | **KBN-130** only after KBN-105 |
|
||||
| 3 | Review support | Finish KBN-110 | **KBN-200 after KBN-120** | Finish KBN-130 |
|
||||
| 4 | — | **KBN-210 after KBN-200** | Review/support | **KBN-220 after KBN-210 DTOs** |
|
||||
| 5 | — | P2 remediation | **KBN-300 then KBN-320** | **KBN-310** |
|
||||
| Wave | coder2 | coder3 | coder4 | coder5 |
|
||||
| ---- | ------------------------- | -------------------------------------- | ------------------------------ | ------------------------------ |
|
||||
| 0 | Wait | **KBN-010** | Wait | Wait |
|
||||
| 1 | **KBN-100** | Review constraint implementation | Wait | Wait |
|
||||
| 2 | **KBN-115** after KBN-100 | **KBN-105** exact freeze, then KBN-110 | **KBN-120** only after KBN-105 | **KBN-130** only after KBN-105 |
|
||||
| 3 | Review support | Finish KBN-110 | **KBN-200 after KBN-120** | Finish KBN-130 |
|
||||
| 4 | — | **KBN-210 after KBN-200** | Review/support | **KBN-220 after KBN-210 DTOs** |
|
||||
| 5 | — | P2 remediation | **KBN-300 then KBN-320** | **KBN-310** |
|
||||
|
||||
Mos alone releases slices and lifts the build hold after independent re-review GO.
|
||||
|
||||
@@ -1460,11 +1460,12 @@ Add to `packages/db/src/schema.ts` in the `preferences` table definition:
|
||||
mutable: boolean('mutable').notNull().default(true),
|
||||
```
|
||||
|
||||
### Held future procedure
|
||||
Generate and apply:
|
||||
|
||||
This historical architecture plan grants **no current command authority**. PostgreSQL execution is non-operative until **KBN-101-00, KBN-101-03, and KBN-101-05** land; do not invoke a PostgreSQL runner from this checkout. After those cards land, the approved future procedure is exactly: external bootstrap → TLS/roles → `mosaic-db-migrator --run` → `mosaic-db-migrator --verify` → Gateway/Compose readiness. Offline migration artifact generation belongs to its owning implementation card and does not activate PostgreSQL execution.
|
||||
|
||||
> **KBN-101 supersession:** `pnpm --filter @mosaicstack/db db:migrate` is superseded and MUST NOT be used. The future runner receives only deployment-injected migration credentials; it accepts no URL, SQL, schema, or role argv.
|
||||
```bash
|
||||
pnpm --filter @mosaicstack/db db:generate # generates migration SQL
|
||||
pnpm --filter @mosaicstack/db db:migrate # applies to PG
|
||||
```
|
||||
|
||||
Platform enforcement keys (seeded with `mutable = false` by gateway `PreferencesService.onModuleInit()`):
|
||||
|
||||
|
||||
@@ -946,11 +946,13 @@ pnpm --filter @mosaicstack/types typecheck
|
||||
|
||||
Expected: All PASS
|
||||
|
||||
**Step 2: Manual smoke test (held)**
|
||||
**Step 2: Manual smoke test**
|
||||
|
||||
This historical TUI smoke test is unavailable until KBN-101-02 supplies a fail-closed Gateway local
|
||||
startup route. Do not start current Compose PostgreSQL or infer a local Gateway from PGlite support.
|
||||
A future reviewed test must use the correct Mosaic CLI package and an independently verified Gateway.
|
||||
```bash
|
||||
cd /home/jwoltje/src/mosaic-mono-v1-worktrees/tui-improvements
|
||||
docker compose up -d
|
||||
pnpm --filter @mosaicstack/cli exec tsx src/cli.ts tui
|
||||
```
|
||||
|
||||
Verify:
|
||||
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
# KBN-101 contract independent security/architecture review
|
||||
|
||||
**Verdict: REQUEST CHANGES**
|
||||
|
||||
## Review identity and scope
|
||||
|
||||
- **Exact reviewed head:** `da742ca2da4a2ff466916c818fe275c4f7ffd384` (`docs(#771): record role-split review evidence`)
|
||||
- **Required comparison:** `origin/main...da742ca2da4a2ff466916c818fe275c4f7ffd384`
|
||||
- **Range:** `82ce3252df38a687c50485f8d048b53ca8db5989` is an ancestor of the reviewed head; the final head adds the scratchpad evidence commit and was reviewed.
|
||||
- **Changed docs:** `docs/PRD.md`, `docs/SITEMAP.md`, `docs/native-kanban-sot/{INDEX.md,KBN-101-DB-ROLE-SPLIT.md,SHARED-CONTRACT.md,TASKS.md}`, and `docs/scratchpads/771-kbn101-db-role-split.md` (300 additions / 25 deletions).
|
||||
- **Reviewed inputs:** issue #771; current DB/Gateway/storage/config/wizard/installer/compose/Portainer/CI sources; all current migration/DDL references; KBN-010, rc.4/rc.5 shared contract, requirements/canon, KBN-100 #769 branch context, and the final scratchpad.
|
||||
- **Repository/provider state:** not modified. The pre-existing `.mosaic/orchestrator/*` dirt was not touched.
|
||||
|
||||
The role graph itself is sound in principle: a NOLOGIN platform database owner, separate NOLOGIN schema owner, NOINHERIT migrator which explicitly `SET ROLE`s, and runtime membership only in a capability role with `SET FALSE` does not create circular privilege or application-created login roles. The split of foundation certification before KBN-100 and immutable-operation certification after KBN-100 is also correctly ordered.
|
||||
|
||||
## Findings
|
||||
|
||||
### HIGH — DDL/migration control plane is not closed at every current entrypoint
|
||||
|
||||
The contract requires an explicit, locked migration phase and forbids Gateway/runtime DDL (`KBN-101-DB-ROLE-SPLIT.md:34-39`), but its KBN-101-02 result merely says migration-capable commands use the migration DTO (`:109`). It does not prohibit or route every existing bypass through that one command.
|
||||
|
||||
Current bypasses include:
|
||||
|
||||
- `runMigrations()` falls back from an argument to `DATABASE_URL` and a hard-coded URL (`packages/db/src/migrate.ts:24-35`), while `drizzle.config.ts` likewise uses `DATABASE_URL` plus a default (`packages/db/drizzle.config.ts:3-9`).
|
||||
- Package scripts expose direct `drizzle-kit migrate` **and** `drizzle-kit push` (`packages/db/package.json:23-26`); `db:push` bypasses the planned journal/fingerprint/lock entirely.
|
||||
- `mosaic storage migrate --run` shells out to the direct `db:migrate` script (`packages/storage/src/cli.ts:413-452`).
|
||||
- The federated integration test can create types, tables, and indexes directly against `DATABASE_URL` and intentionally operates without a Drizzle ledger (`packages/db/src/federation.integration.test.ts:28-30,46-134`).
|
||||
|
||||
**Failure mode:** a runtime or CI environment with only `DATABASE_URL`, or an operator invoking an existing command, can apply unverified DDL outside the lock, `SET ROLE` preflight, exact-ledger gate, and deployment sequencing. This breaks the requested fail-closed split even if Gateway startup is repaired.
|
||||
|
||||
**Required remediation:** amend KBN-101-02/03/06 to enumerate these entrypoints and make the dedicated migrator runner the only PostgreSQL DDL path. Production-like `db:push` must be removed/blocked; `db:migrate`, `storage migrate --run`, and migration tests must invoke the same migration runner with `DATABASE_MIGRATION_URL`, lock, identity preflight, and ledger verification. Tests needing schema must consume a pre-migrated disposable database, or be explicitly run only by that migration phase. Add negative tests showing each command refuses `DATABASE_URL`-only execution and cannot reach DDL.
|
||||
|
||||
### HIGH — TLS requirement has no deployable server/bootstrap contract
|
||||
|
||||
The contract correctly requires a mounted CA and hostname-verified TLS (`KBN-101-DB-ROLE-SPLIT.md:25,28,93-95`). However KBN-101-05 promises only a “migration phase and secret binding boundary” (`:112`), not PostgreSQL server TLS, certificate issuance/SANs, CA distribution, startup ordering, or the fresh/existing-database bootstrap trust path.
|
||||
|
||||
Current standalone and federated compose expose plain PostgreSQL with no server TLS configuration or CA mount (`docker-compose.yml:2-14`; `docker-compose.federated.yml:27-44`). The Portainer test stack passes a single plaintext in-network URL and uses the same database login for Gateway and database bootstrap (`deploy/portainer/federated-test.stack.yml:51-60,110-117`).
|
||||
|
||||
**Failure mode:** enforcing the mandatory CA makes current local standalone/federated topologies unable to start; relaxing it to make bootstrap work silently violates K101-REQ-03. A first database cannot be safely migrated until the server certificate, its SAN for the actual service/DNS name, and trusted CA are provisioned, but this lifecycle is not owned or tested.
|
||||
|
||||
**Required remediation:** add a concrete KBN-101-00/05 TLS bootstrap sub-contract: issuer/CA owner; server key/cert and SAN inputs; secure storage/mount permissions; `postgresql.conf`/container TLS enablement; migration and runtime CA mounts; hostname used by each compose/Swarm service; readiness only after TLS authentication; CA overlap rotation; and an existing-database transition. Require a disposable standalone and federated/Swarm test to prove verified TLS succeeds and missing CA, wrong CA, wrong SAN, and `sslmode` downgrade fail before readiness. Do not merge KBN-101-05 with an implicit plaintext exception.
|
||||
|
||||
### HIGH — exact ledger fingerprint and historical 0009 repair are underspecified for existing databases
|
||||
|
||||
The contract requires an “ordered complete set” and rejection of out-of-order rows (`KBN-101-DB-ROLE-SPLIT.md:36-38`), but does not define the canonical serialized tuple, ledger ordering source, or safe upgrade rule for a historical ledger. The current ledger stores only `id`, `hash`, and `created_at` (`packages/db/src/migrate.ts:70-82,105-107`). Its journal is demonstrably non-monotonic: `0008` has `when=1776822435828`, followed by `0009` at `1745280000000` (`packages/db/drizzle/meta/_journal.json:62-79`); the existing PostgreSQL runner documents that this causes skipping (`packages/db/src/migrate.ts:29-35`).
|
||||
|
||||
**Failure mode:** an implementation can either reject a legitimate historical database after correcting 0009, or accept a reordered/duplicated ledger because no precise comparison rule exists. A count/hash-set implementation would fail to detect the condition that this contract explicitly calls unsafe; physical `id` order is not an adequate substitute after historical repair.
|
||||
|
||||
**Required remediation:** freeze a versioned manifest algorithm before implementation: canonical record fields (at least journal index/tag, corrected logical order, migration content hash, and an explicit migration-manifest version), canonical byte serialization, SHA-256 input, and exact observed-ledger mapping. State whether physical ledger insertion order is normative; if not, compare hash-to-manifest tuples rather than timestamps. Add an idempotent migrator-only 0009 existing-database remediation/reconciliation procedure with backup/rollback evidence. Require clean, pre-0009, 0009-skipped, 0009-applied-late, duplicate, unknown, missing, corrupt-pair, and stale-replica cases. No manual ledger insertion is an acceptable production recovery path.
|
||||
|
||||
### MEDIUM — advisory-lock namespace is collision-prone and lacks a fixed identifier contract
|
||||
|
||||
The specified lock is `pg_try_advisory_lock(hashtext('mosaic-schema-migration-v1'))` (`KBN-101-DB-ROLE-SPLIT.md:34`). `hashtext` produces a 32-bit key. Session ownership/crash behavior is otherwise correctly stated (one session, same-session release, connection-close release), but an unrelated database user can accidentally collide or deliberately hold the key and force `DATABASE_MIGRATION_LOCKED`.
|
||||
|
||||
**Failure mode:** avoidable migration denial of service in a shared PostgreSQL database. The current repository already uses separate `hashtext` advisory-lock names for migrate-tier, demonstrating the need for a documented namespace rather than a collision-prone implicit one.
|
||||
|
||||
**Required remediation:** freeze a two-int advisory-lock namespace (fixed documented class/object values) or a documented 64-bit `hashtextextended` key with fixed seed; keep acquisition, migration, verification, and release on the single `max:1` migrator session. Add tests for concurrent migration, connection loss/crash release, readiness while the lock holder is active, and an unrelated lock-key non-interference case.
|
||||
|
||||
### MEDIUM — identifier safety and `search_path` verification need executable constraints
|
||||
|
||||
The contract rightly requires `pg_catalog, <mosaic_application_schema>` and rejects writable paths (`KBN-101-DB-ROLE-SPLIT.md:54,70-77`), but uses dynamic placeholders for database/schema and does not state how migration/bootstrap SQL will avoid identifier interpolation. Existing code has raw-SQL facilities (`packages/storage/src/migrate-tier.ts` uses `.unsafe`), so this is not merely theoretical.
|
||||
|
||||
**Failure mode:** a future operator-configured database/schema value that reaches bootstrap or `SET search_path` through raw string construction can inject DDL, or a pooled connection can retain a mutable search path.
|
||||
|
||||
**Required remediation:** require fixed allowlisted identifiers or server-side identifier quoting (`format('%I', ...)`) only; never interpolate URL/config values into SQL. Set and verify the trusted path per connection/session before any query (`SET LOCAL` inside transactions where applicable), forbid `public`/`$user` additions, and add injection-shaped identifier and pooled-connection reset negatives. Include this in KBN-101-00/01 tests.
|
||||
|
||||
## Acceptance and threat traceability
|
||||
|
||||
| Requirement / threat | Review result | Evidence or blocking finding |
|
||||
| --- | --- | --- |
|
||||
| K101-REQ-01 / AC-K101-01 split runtime/migration URLs | Partial | Role/DTO boundary is coherent; HIGH DDL-path finding requires all current commands to be closed. |
|
||||
| K101-REQ-02 / AC-K101-02 explicit migration/readiness | Blocked | HIGH ledger definition and HIGH DDL-bypass findings. |
|
||||
| K101-REQ-03 / AC-K101-03 least privilege, TLS, grants | Partial | Role model, default privileges, ledger read-only, TEMP/function checks are well specified (`KBN-101...:47-56,70-79`); HIGH TLS bootstrap and MEDIUM identifier constraints remain. |
|
||||
| K101-REQ-04 / AC-K101-04 immutable relations | Correctly deferred | KBN-101-09 after KBN-100 is the correct serial gate (`KBN-101...:58-66,115-118`); no synthetic-only certification claim found. |
|
||||
| K101-REQ-05 / AC-K101-05 N-1, secrets, rollback | Partial | No owner-runtime exception and rollback keeps migration URL out of Gateway (`:83-95`); deployable TLS and full command inventory are missing. |
|
||||
| K101-REQ-06 / AC-K101-07 KBN gates and DAG | Structurally sound | DAG is acyclic: 00→01/{03}; 02→06; 00/01/03→05; 00/04/05/06→07→08→KBN-100→09→KBN-105. KBN-100’s current branch contains docs-only baseline tracking, not schema implementation. |
|
||||
| T: runtime DDL / migration fallback | Blocked | HIGH finding 1. Current Gateway/storage, CLI, direct Drizzle scripts, and integration DDL require explicit closure. |
|
||||
| T: race/crash/readiness | Partial | Same-session nonblocking lock and replica-unready rules are present (`:34-38`); lock namespace remediation required. |
|
||||
| T: immutable evidence rewrite | Correctly staged | Explicit INSERT/SELECT-only matrix and RESTRICT retention are retained; proof is properly after table creation. |
|
||||
| T: secret leakage / TLS downgrade | Partial | Redaction and distinct Vault paths are specified (`:93-97`), but no server TLS/bootstrap implementation contract exists. |
|
||||
|
||||
## Unresolved assumptions
|
||||
|
||||
1. `standalone` and `federated` are the complete PostgreSQL production-like set (K101-A1).
|
||||
2. Each eligible deployment can execute a dedicated migration Job/one-shot phase (K101-A2).
|
||||
3. Vault path names are targets, not verified existing paths; deployment ownership remains to be established.
|
||||
4. PostgreSQL 17 is available for the selected membership and advisory-lock implementation.
|
||||
5. The required server-side TLS issuer/certificate lifecycle and Swarm/compose secret transport have not been decided; this is blocking, not a permissible implicit plaintext bootstrap.
|
||||
6. Historical databases containing the 0009 journal/ledger anomaly have no frozen reconciliation procedure.
|
||||
|
||||
## Independent test and consistency evidence
|
||||
|
||||
Read-only checks run in this review:
|
||||
|
||||
| Check | Result |
|
||||
| --- | --- |
|
||||
| `git diff --check origin/main...da742ca2...` | PASS |
|
||||
| `pnpm exec prettier --check` on all seven changed docs | PASS |
|
||||
| `pnpm exec tsc --noEmit -p docs/native-kanban-sot/tsconfig.json` | PASS |
|
||||
| `docker compose -f docker-compose.yml config --quiet` (isolated test ports) | PASS |
|
||||
| `docker compose -f docker-compose.federated.yml --profile federated config --quiet` (isolated test ports) | PASS |
|
||||
| Static journal inspection | FAILS the required monotonic ordering premise: 0008 → 0009 `when` decreases; current runner documents skipping behavior. |
|
||||
| Static DDL-entrypoint inventory | Found direct Drizzle scripts, storage CLI shell-out, runtime extension/migration calls, fleet backlog migration, tier probe extension creation, and a direct-DLL federated integration test. |
|
||||
|
||||
No live database, Vault, CI, deployment, issue, PR, or repository mutation was performed. The pass results validate documentation syntax/contract compilation and compose syntax only; they do **not** certify the proposed security behavior.
|
||||
|
||||
## Conclusion
|
||||
|
||||
Do not merge this frozen contract as implementation-ready until the HIGH findings are corrected and independently re-reviewed. The central role ownership/default-privilege design, immutable-table staging, and KBN-100/KBN-105 serial gating should be retained; they are not the reason for this REQUEST CHANGES verdict.
|
||||
@@ -1,148 +0,0 @@
|
||||
# Issue #755 — Logical Mos identity and connector lease fencing
|
||||
|
||||
- Task: `MOS-PORT-M1-001`
|
||||
- Branch: `feat/mos-logical-identity-fencing`
|
||||
- Base: `origin/main`
|
||||
- Started: 2026-07-14
|
||||
- Working budget: 38K tokens (task ledger estimate); one implementation lane, bounded to M1.
|
||||
|
||||
## Objective
|
||||
|
||||
Implement the first runtime-portability security boundary: normalized logical-agent identity plus a PostgreSQL-durable exclusive connector lease and server-validated fencing grants.
|
||||
|
||||
## Scope
|
||||
|
||||
- Normalized identity contract independent of harness/provider-native session IDs.
|
||||
- DB migration/schema/repository for one lease per tenant/logical-agent/binding.
|
||||
- CAS acquire/takeover, monotonic epoch, TTL, heartbeat, release, expiry handling.
|
||||
- Server-derived grants bound to tenant, logical agent, binding, connector, scopes, expiry, and lease epoch.
|
||||
- Reject and credential-safely audit stale, expired, forged, unauthorized, cross-tenant, and cross-binding grants before adapter side effects.
|
||||
- Runtime adapter boundary consumes normalized lease context.
|
||||
- Unit, migration, close/reopen, concurrency, abuse, and gateway integration tests.
|
||||
- Required developer/operations documentation for schema and security behavior.
|
||||
|
||||
## Explicit exclusions
|
||||
|
||||
No checkpoint/handoff payloads, exactly-once journal/receipts, concrete Claude/Pi/Codex harness adapter, channel cutover, or full cross-harness failover E2E.
|
||||
|
||||
## Reconciliation baseline
|
||||
|
||||
- Prospective remediation handoff: `web1:coder1`; PR [#757](https://git.mosaicstack.dev/mosaicstack/stack/pulls/757), issue [#755](https://git.mosaicstack.dev/mosaicstack/stack/issues/755).
|
||||
- Before rebase: `dff8ce4f79ef90370c29d925002118a708010091`; required base: `origin/main` at `2e2280070ae67288be45f41743cf67052a8ca5a6`; original branch base: `d0771835542deab048ad8e79f271e3abdb6151f7`.
|
||||
- Provider metadata: #757 is open, targets `main`, head is `feat/mos-logical-identity-fencing`, prior CI is green, and the provider reports it is not mergeable because of conflicts.
|
||||
- Affected delivery paths: `apps/gateway/src/agent/agent.module.ts`; connector-lease gateway repository/service and three focused tests; `packages/agent/src/connector-lease.ts` plus test/export; `packages/types/src/agent/connector-lease.dto.ts` plus test/export; `packages/db/src/schema.ts`, migration `0016_salty_morlocks.sql`, Drizzle snapshot/journal; `docs/PRD.md`, `docs/SITEMAP.md`, MOS architecture/operations pages, this scratchpad, and `docs/tess/TASKS.md`.
|
||||
- Read-only merge-tree inspection found only `docs/PRD.md` and `docs/SITEMAP.md` conflicts. Current-main #756 channel contracts, #758 roster-v2 structural compiler, and Native Kanban SOT use distinct contract domains; no substantive architecture collision was identified before mechanical reconciliation.
|
||||
- Reconciliation constraints: retain all current-main #752/#756/#758/KBN content; add only nonduplicative #755 references; do not alter semantics or conflate connector leases/grants with Kanban task leases/fences, local Fleet leases, auth sessions, ResetSession generations, or federation grants.
|
||||
|
||||
## Plan (TDD RED → GREEN → REFACTOR)
|
||||
|
||||
1. Map existing contracts, DB/migration conventions, gateway authorization/audit boundaries, and test infrastructure.
|
||||
2. Add failing contract/repository/concurrency/restart/abuse/gateway tests and capture RED evidence.
|
||||
3. Implement the smallest normalized contracts, schema/migration/repository, grant validator, audit sink, and gateway service/adapter boundary needed to pass.
|
||||
4. Refactor for clear invariants and credential-safe observability; rerun focused suites.
|
||||
5. Run package/repo typecheck, lint, format, and appropriate tests.
|
||||
6. Run independent code + security review, remediate, and re-review.
|
||||
7. Inspect the final diff for security/scope drift; commit; queue guard; push; open PR with `Refs #755` and exact verification; stop without merge/issue closure.
|
||||
|
||||
## Constraints and safety notes
|
||||
|
||||
- `docs/tess/TASKS.md` is orchestrator-only and will not be edited.
|
||||
- Existing dirty `.mosaic/orchestrator/mission.json` and `.mosaic/orchestrator/session.lock` are launcher/orchestrator state and will not be staged or altered intentionally.
|
||||
- No client-supplied identity may confer authority.
|
||||
- No credential, token, or raw grant material may be persisted to audit/log output.
|
||||
- Existing authorization checks remain intact; fencing is an additional fail-closed layer.
|
||||
|
||||
## Assumptions resolved from existing architecture
|
||||
|
||||
- `ASSUMPTION:` M1 exposes no public lease endpoint. The gateway service is an internal policy surface with deny-all default policy because concrete connector activation/cutover is explicitly deferred.
|
||||
- `ASSUMPTION:` Fencing epochs use PostgreSQL `bigint` and cross-module decimal strings, preserving JSON portability without JavaScript number precision loss.
|
||||
- `ASSUMPTION:` Process-local grant provenance intentionally fails closed across restart; durable lease/epoch state survives and fresh grants require current policy + lease validation.
|
||||
|
||||
## TDD evidence
|
||||
|
||||
RED observed before implementation:
|
||||
|
||||
- `corepack pnpm --filter @mosaicstack/types exec vitest run src/agent/connector-lease.dto.spec.ts` → failed to load missing `connector-lease.dto.js`.
|
||||
- `corepack pnpm --filter @mosaicstack/agent exec vitest run src/connector-lease.test.ts` → failed to load missing `connector-lease.js`.
|
||||
- Gateway focused tests failed before implementation because the new repository/service boundaries did not exist (workspace dependencies were then built before behavioral GREEN runs).
|
||||
|
||||
GREEN to date:
|
||||
|
||||
- Types contract: 6/6 passed.
|
||||
- Agent grant/fencing unit suite: 5/5 passed.
|
||||
- Gateway PGlite repository + policy/side-effect integration: 7/7 passed; 1 real-PostgreSQL test skipped when `DATABASE_URL` absent.
|
||||
- Real PostgreSQL focused run with configured `DATABASE_URL`: 1/1 passed (credential value not emitted in reports).
|
||||
|
||||
## Documentation checklist
|
||||
|
||||
- [x] `docs/PRD.md` contains current MOS-PORT M1 scope and acceptance criteria.
|
||||
- [x] Developer architecture: `docs/architecture/mos-runtime-portability-m1.md`.
|
||||
- [x] Admin/operations guidance: `docs/guides/mos-connector-lease-operations.md`.
|
||||
- [x] `docs/SITEMAP.md` links both pages.
|
||||
- [x] No user-guide change: M1 exposes no user-facing flow or channel cutover.
|
||||
- [x] No OpenAPI/endpoint-index change: M1 adds no HTTP endpoint.
|
||||
- [x] Migration/restart/rollback safety and credential-safe audit constraints documented.
|
||||
- [x] Canonical source remains in-repo; no external publishing action is in scope.
|
||||
- [x] Independent review confirms documentation matches implementation; implementation-specific findings were remediated.
|
||||
|
||||
## Independent review and remediation
|
||||
|
||||
Codex code/security review ran in multiple rounds. Findings and root-cause remediations:
|
||||
|
||||
1. Policy could not inspect requested scope/TTL → policy subject now receives normalized requested scopes and explicit requested TTL.
|
||||
2. Unbounded authority lifetime → hard defaults cap leases at 5 minutes and grants at 30 seconds; overrides may only tighten; over-limit tests added.
|
||||
3. Cross-tenant denial could audit under submitted tenant → mismatch audit uses authenticated tenant plus sanitized `untrusted` target metadata; integration assertion added.
|
||||
4. Malformed forged grant could break the denial/audit path → runtime-safe shape validation with sanitized fallback audit; malformed-input test added.
|
||||
5. Gateway integration test depended on prior test state → denial test now seeds a unique binding itself; isolated `-t` run passed.
|
||||
6. Reviewer repeatedly identified launcher-generated `.mosaic/orchestrator/*` state; those files remain unstaged and excluded from the implementation commit.
|
||||
|
||||
Latest independent security review: no critical/high/medium/low findings. Final commit-level code review remains to run after the intended diff is committed without launcher state.
|
||||
|
||||
## Verification evidence
|
||||
|
||||
- Focused contracts/fencing: types 6/6; agent 9/9.
|
||||
- Gateway focused PGlite repository/policy integration: 7/7; isolated denial test 1/1.
|
||||
- Real PostgreSQL close/reopen/CAS test: 1/1 with configured `DATABASE_URL`.
|
||||
- Root `corepack pnpm typecheck`: 42/42 Turbo tasks passed.
|
||||
- Root `corepack pnpm lint`: 23/23 Turbo tasks passed.
|
||||
- Root `corepack pnpm format:check`: all matched files passed.
|
||||
- Root `corepack pnpm test`: 42/42 Turbo tasks passed; gateway 616 passed / 12 environment-gated skipped; DB 19 passed / 7 environment-gated skipped; Mosaic 650 passed.
|
||||
|
||||
## Known residual risks
|
||||
|
||||
- Concrete connector policies and Claude/Pi/Codex adapters are intentionally deferred; production policy defaults deny-all.
|
||||
- Gateway pre-side-effect validation cannot make an external system exactly-once. Adapters must propagate/enforce the epoch at downstream effect boundaries; receipts/journaling are later #754 scope.
|
||||
- Migration rollback is additive-only; dropping lease/audit tables is intentionally manual to avoid destroying authority/audit evidence.
|
||||
|
||||
## Commit-level review remediation
|
||||
|
||||
- Commit-level Codex code review found one `should-fix`: heartbeat, release, and grant issuance authorized caller-supplied lease fields before canonical normalization.
|
||||
- TDD RED: the isolated gateway policy-boundary test showed mixed-case/padded logical agent, binding, connector, scope, and epoch values reaching policy unchanged.
|
||||
- Remediation: exported the coordinator's canonical lease normalizer and applied it at the gateway boundary before tenant/policy checks and coordinator dispatch for heartbeat, release, and grant issuance.
|
||||
- GREEN: isolated policy test 1/1; focused types 6/6, agent 9/9, gateway 8/8; root typecheck 42/42, lint 23/23, format check passed, and root tests 42/42 (gateway 617 passed / 12 environment-gated skipped).
|
||||
- Commit-level security review remained clean: no critical/high/medium/low findings.
|
||||
|
||||
## Durable grant-expiry review remediation
|
||||
|
||||
- Final commit review found a second `should-fix`: grant expiry was capped against submitted lease metadata after current-authority validation, rather than the durable lease row.
|
||||
- TDD RED: a crafted same-authority lease with a later submitted expiry produced a grant expiring after the durable row.
|
||||
- Remediation: grant authority fields and expiry now derive from the durable current lease; submitted scopes remain an additional narrowing constraint.
|
||||
- GREEN: focused agent fencing suite 10/10.
|
||||
|
||||
## Current-main reconciliation (2026-07-14)
|
||||
|
||||
- Rebased the existing PR branch from `dff8ce4f79ef90370c29d925002118a708010091` (old base `d0771835542deab048ad8e79f271e3abdb6151f7`) onto `origin/main` `2e2280070ae67288be45f41743cf67052a8ca5a6`; current uncommitted reconciliation head is `d190732a550918161b91d3eb54640f4ea0e2e499`.
|
||||
- Resolved only `docs/PRD.md` and `docs/SITEMAP.md`: preserved current-main #752 Native Kanban, #756 official-channel, and #758 FCM material; retained the nonduplicative #755 M1 workstream and placed its two documentation links in the existing Runtime-neutral Mos section. No #755 source semantics changed.
|
||||
- Compatibility review confirmed separate authority domains: connector lease/epoch/grant remains distinct from KBN task leases/fences, local Fleet roster lifecycle, auth sessions, ResetSession context, and federation grants. No channel cutover, adapter activation, checkpoint/exactly-once behavior, UI convergence, or #754 expansion was added.
|
||||
- Focused verification after building the required workspace dependencies: types contract 6/6; agent fencing/grant 10/10; gateway repository/PGlite and policy integration 8/8. The focused real-PostgreSQL test was skipped because `DATABASE_URL` was not configured; no credentials were inspected or emitted.
|
||||
- Generated schema check: `pnpm --filter @mosaicstack/db db:generate` reported no schema changes; migration `0016_salty_morlocks`, snapshot, and journal were unchanged by generation.
|
||||
- Full verification: `pnpm typecheck` 42/42; `pnpm lint` 23/23; `pnpm format:check` passed; `pnpm test` 42/42 (gateway 627 passed / 12 environment-gated skipped). Scoped diff check and local documentation-link scan passed.
|
||||
- Pending only: commit this reconciliation record, queue guard, force-with-lease push of the rebased existing branch, then fresh independent DB/code/security review and Ultron. Do not claim merge or issue closure.
|
||||
|
||||
## Durable lifecycle-authority remediation (2026-07-14)
|
||||
|
||||
- Independent review found that heartbeat and release authorized the submitted lease, allowing forged lifecycle scope data to influence policy before the durable row was consulted.
|
||||
- Remediation: lifecycle operations tenant-check the submitted lease, load the durable current lease, compare tenant, logical agent, binding, lease UUID, connector, epoch, and canonical ordered scopes, audit and deny any absent/mismatched authority, then run policy and coordinator lifecycle calls with the durable lease. Coordinator CAS/fencing checks remain unchanged.
|
||||
- Adversarial gateway integration coverage proves forged `tool.execute` scopes on a durable `runtime.send` lease deny before policy or mutation, preserve heartbeat/release state, and write a denial audit for both lifecycle actions; canonical heartbeat and release remain accepted.
|
||||
- Verification: focused types 6/6; agent 10/10; gateway PGlite integration/repository 9/9 with one `DATABASE_URL`-gated PostgreSQL test skipped; Drizzle check passed; root typecheck 42/42; lint 23/23; format check passed; root test 42/42 (gateway 628 passed / 12 environment-gated skipped).
|
||||
- Pending: commit, queue-guard, force-with-lease push, then independent DB/code/security rereview and CI. Do not merge or close #755.
|
||||
@@ -1,148 +0,0 @@
|
||||
# FCM-M1-002 — Shared role resolution
|
||||
|
||||
- **Task:** `FCM-M1-002`
|
||||
- **Issue:** `mosaicstack/stack#758`
|
||||
- **Branch:** `feat/758-shared-role-resolution`
|
||||
- **Starting head:** `32e75c67b094de443d37fe7d5ff8d25cdfc8b39d`
|
||||
- **Role:** implementation worker; independent review and merge remain outside this worker
|
||||
|
||||
## Objective
|
||||
|
||||
Reuse the existing baseline-plus-`roles.local` persona resolver as the sole class authority for roster-v2 semantics, profile validation, provisioning, and launch/persona resolution. Add exact approved alias canonicalization, fail-closed semantic validation, immutable canonical-class authority contracts, required baseline roles, and operator documentation without implementing lifecycle, mutation, credentials, certificate workflow, or later FCM cards.
|
||||
|
||||
## Budget
|
||||
|
||||
- Soft budget: **25K tokens**.
|
||||
- Strategy: inspect once, implement in small TDD units, run focused suites before the full package gate, and avoid unrelated refactors or M1-003/M2/M4 scope.
|
||||
|
||||
## Plan
|
||||
|
||||
1. Map the existing persona resolver, roster-v2 compiler, profile/provision consumers, launch resolution, role library, and focused tests.
|
||||
2. Write denial/invariant tests first for aliases, canonicalization-before-override, unreadable roles, authority boundaries, policy mismatch, canonical provision output, and resolver parity.
|
||||
3. Run the focused suites and record the expected red evidence.
|
||||
4. Implement one shared canonical resolution and authority contract in/through `fleet-personas.ts`; delegate roster semantic validation and profile/provision paths to it.
|
||||
5. Add baseline `validator`, `team-leader`, and `interaction` role contracts plus `LIBRARY.md` entries while retaining `operator-interaction` compatibility.
|
||||
6. Add the required role reference, alias migration, customization guide, and roster-v2 semantic handoff documentation.
|
||||
7. Run focused tests, the full `@mosaicstack/mosaic` suite, typecheck, lint, Prettier, `git diff --check`, situational verification, independent code/security review, and remediation.
|
||||
8. Commit with the required co-author trailer, run the CI queue guard, push the existing branch, and create/update exactly one PR to `main` with `Refs #758`.
|
||||
|
||||
## TDD evidence
|
||||
|
||||
### Red
|
||||
|
||||
After installing worktree-local dependencies and building `@mosaicstack/db`, the pre-implementation
|
||||
focused run collected the intended tests and failed as expected:
|
||||
|
||||
```text
|
||||
2 test files failed; 32 tests failed; 32 tests passed
|
||||
```
|
||||
|
||||
Expected failures named the missing `canonicalizeRoleClass`,
|
||||
`authorityForCanonicalClass`, and `validateRosterV2Semantics` APIs, absent requested/canonical typed
|
||||
output, and unresolved required canonical role contracts. An earlier run that failed before test
|
||||
collection on an unresolved `yaml` dependency was treated as environment setup, not TDD evidence.
|
||||
|
||||
### Green
|
||||
|
||||
Focused role-resolution and affected service fixtures:
|
||||
|
||||
```text
|
||||
6 test files passed; 109 tests passed
|
||||
```
|
||||
|
||||
The focused set covers personas, profiles, provision, launch persona contract, roster-v2 semantics,
|
||||
and the operator-interaction service fixture. The final profile tests also cover readable lead/floor
|
||||
compatibility and canonical collision denial.
|
||||
|
||||
## Tests and gates
|
||||
|
||||
- Focused suites: pass, **6 files / 109 tests**.
|
||||
- Full `@mosaicstack/mosaic` suite: pass, **50 files / 713 tests**. Workspace package build outputs
|
||||
were prepared first because a clean worktree has no dependency `dist` entries.
|
||||
- `pnpm --filter @mosaicstack/mosaic typecheck`: pass.
|
||||
- `pnpm --filter @mosaicstack/mosaic lint`: pass.
|
||||
- Prettier check over every changed file: pass.
|
||||
- `git diff --check`: pass.
|
||||
- Runtime/file-boundary evidence: real role library, profile/provision filesystem integration,
|
||||
launch-time synchronous contract injection, v1 roster parser round-trip, roster-v2 semantic
|
||||
filesystem checks, and operator-interaction service fixtures all pass without live mutation.
|
||||
- Independent code review: **APPROVE**, no blocking or non-blocking findings; reviewed complete
|
||||
tracked/untracked delta including the canonical collision guard. Residual: roster-v2 semantic
|
||||
validation is an explicit async handoff with production caller wiring owned by later work.
|
||||
- Independent security review: **APPROVE**, no verified authority/security findings on the final
|
||||
delta.
|
||||
|
||||
### Post-PR fail-closed remediation
|
||||
|
||||
Independent rereview found resolver fail-open edges that the original green PR head did not cover. The
|
||||
remediation remained uncommitted until every finding was reproduced red-first and the same reviewer
|
||||
approved the complete two-file delta.
|
||||
|
||||
Final regression evidence:
|
||||
|
||||
```text
|
||||
persona resolver: 47/47
|
||||
focused affected suites: 6 files / 138 tests
|
||||
root-container resolver suites: 86/86
|
||||
full canonical run: 42/42 Turbo tasks; Mosaic 50 files / 733 tests
|
||||
```
|
||||
|
||||
DB migration, typecheck, lint, Prettier, and `git diff --check` also passed. Coverage now proves:
|
||||
|
||||
- unreadable, unscannable, direct-dangling, ancestor-dangling, and literal `..` traversal override paths
|
||||
fail closed across async, sync, listing, and status APIs;
|
||||
- genuinely missing override directories still permit baseline fallback;
|
||||
- cached missing scans are revalidated before fallback;
|
||||
- marker-defined identity and domain metadata are revalidated on the second read;
|
||||
- `LIBRARY.md` rows and incidental later markers cannot define, shadow, or advertise personas.
|
||||
|
||||
Exact-head pipeline `1819` passed for rebased head `4d990eee…`, but the independent reviewer-of-record
|
||||
returned **REQUEST CHANGES** after reproducing three additional edge failures: a canonical filename could
|
||||
inherit protected authority despite a conflicting explicit first marker, cached `scanned` absence could
|
||||
miss an override created before baseline fallback, and inherited plain-object names such as `constructor`
|
||||
could corrupt alias/authority lookup. Merge remained held.
|
||||
|
||||
Each failure was reproduced red-first in the persona suite (4 failing assertions), then remediated without
|
||||
expanding card scope. Explicit first markers now own identity and filename fallback applies only to
|
||||
markerless contracts; every second read rejects a newly introduced conflicting marker regardless of
|
||||
cached classification; cached async resolution re-scans the override layer immediately before every
|
||||
baseline fallback; alias and authority registries require own-property matches. Current uncommitted
|
||||
evidence is persona **52/52**, focused affected suites **6 files / 143 tests**, and full Mosaic package
|
||||
**50 files / 738 tests**, plus typecheck, lint, Prettier, and `git diff --check`. Independent
|
||||
finding-specific rereview **APPROVED** the complete uncommitted three-file remediation after direct
|
||||
adversarial reproduction of all three findings and the follow-up markerless TOCTOU. All post-commit
|
||||
exact-head gates remain required.
|
||||
|
||||
## Risks and boundaries
|
||||
|
||||
- **Security-sensitive authority:** authority must derive only from canonical class, never role prose, aliases, display names, or tool-policy text.
|
||||
- **Resolver divergence:** no second regex, registry, scanner, or prose parser may be introduced.
|
||||
- **Alias capture:** aliases must canonicalize before baseline/`roles.local` lookup so local files cannot redefine legacy aliases as separate authority.
|
||||
- **Readable persona requirement:** semantic success requires a resolved readable persona, not class-set membership.
|
||||
- **Scope control:** no roster mutation, lifecycle, lease issuance, certificate workflow/storage, credentials, remote reconciliation, provision-v2 conversion, or shipped-example disposition execution.
|
||||
- **Coordination:** `docs/TASKS.md` is read-only and remains orchestrator-owned.
|
||||
|
||||
## Acceptance-evidence mapping
|
||||
|
||||
| Requirement / criterion | Verification evidence |
|
||||
| --- | --- |
|
||||
| `FCM-REQ-02` shared semantic resolver | Async/sync resolver parity; roster-v2 delegates batched scans and resolution; profiles/provision and launch reuse `fleet-personas.ts`; no second scanner or class-marker regex added. |
|
||||
| `FCM-REQ-07` canonical classes and authority boundaries | Exact alias and non-alias tests; immutable authority invariant tests; all required canonical contracts resolve through the real role library. |
|
||||
| `AC-FCM-01` structural + semantic roster validation | Synchronous parser/normalizer tests remain intact; async semantic tests cover aliases, custom roles, unreadable/unresolved roles, `LIBRARY`-only rejection, and bidirectional protected policy mismatch. |
|
||||
| `AC-FCM-07` protected authority invariants | Denial tests prove merge-gate-only merge, validator certificate-only, orchestrator/team-leader/interaction limits, no implicit custom-role authority, and canonical tool-policy matching. |
|
||||
|
||||
## Documentation
|
||||
|
||||
- `docs/fleet/reference/role-classes.md`
|
||||
- `docs/fleet/migration/legacy-class-aliases.md`
|
||||
- `docs/fleet/how-to/customize-roles.md`
|
||||
- `docs/fleet/reference/roster-v2-fields.md` semantic handoff
|
||||
- Baseline role contracts and `LIBRARY.md` rows for `validator`, `team-leader`, and `interaction`
|
||||
|
||||
## Residual risks
|
||||
|
||||
- Provisioning remains intentionally v1 and does not emit `reports_to`; canonical topology is retained
|
||||
in its typed seat/summary path only, matching the existing v1 parser boundary.
|
||||
- Alias support remains for compatibility; new configuration should emit canonical identities.
|
||||
- This card defines authority metadata and validation only. Enforcement workflows for leases,
|
||||
certificates, lifecycle, and mutation remain owned by later FCM cards.
|
||||
@@ -1,37 +0,0 @@
|
||||
# FCM-M1-003 — Executable example/profile/service-preset dispositions
|
||||
|
||||
- **Task / issue:** FCM-M1-003 / #758
|
||||
- **Branch / base:** `test/758-example-profile-dispositions` from `origin/main` `a5e8e554012f27898e035d2882a8e47e1a02fe97`
|
||||
- **Objective:** Make every artifact in the M0 legacy disposition inventory executable evidence: it must validate canonically, be explicitly retained as a v1 fixture, or be retired with a replacement/deprecation link.
|
||||
- **Scope:** Validation and explicit version/retirement metadata only for shipped examples, profiles, and the operator-interaction service preset. Reuse the central resolver and existing v2 roster compiler.
|
||||
- **Out of scope:** Generated environment boundaries, CRUD, reconciliation/apply, migration, live fleet mutation, and `docs/TASKS.md`.
|
||||
- **Budget:** 20K card allocation; use focused package tests before full package validation.
|
||||
|
||||
## Plan
|
||||
|
||||
1. Inventory exact shipped artifacts and existing compiler/resolver/profile tests.
|
||||
2. Add failing behavior tests covering all listed artifacts and their documented disposition.
|
||||
3. Implement minimal declarative fixture/disposition validation; do not add a role/class resolver.
|
||||
4. Run focused and package quality gates; obtain independent code and security review.
|
||||
5. Commit, queue-guard, push, open one `main` PR with `Refs #758`.
|
||||
|
||||
## Progress
|
||||
|
||||
- Intake complete: verified no branch, worktree, or open PR for this card before creating this isolated worktree.
|
||||
- Requirements read: FCM PRD, FCM-M1-003 task row, M0 disposition inventory, delivery/QA/documentation guides.
|
||||
- TDD: RED recorded with `pnpm --filter @mosaicstack/mosaic test -- example-profile-dispositions.spec.ts` failing because the new module did not exist; GREEN recorded after the minimal guard implementation. The focused suite now has 4 passing tests, including undeclared-artifact and missing-explicit-v1-version denials.
|
||||
- Independent review: initial code review found the service policy path was hardcoded; remediation iterates declared `canonical-service-policy` artifacts. Exact-head code review approved and exact-head security review found no issues.
|
||||
|
||||
## Risks / decisions
|
||||
|
||||
- The M0 inventory permits unresolved legacy roles only when explicitly v1-versioned or retired. Do not infer aliases beyond the three approved by FCM-M1-002.
|
||||
- `docs/TASKS.md` is orchestrator-owned and will not be edited.
|
||||
|
||||
## Verification evidence
|
||||
|
||||
- Focused TDD guard: `pnpm --filter @mosaicstack/mosaic test -- example-profile-dispositions.spec.ts` — PASS (4 tests).
|
||||
- Full package suite: `pnpm --filter @mosaicstack/mosaic test` — PASS (51 files, 742 tests).
|
||||
- Static gates: `pnpm --filter @mosaicstack/mosaic typecheck`, `pnpm --filter @mosaicstack/mosaic lint`, and `pnpm format:check` — PASS.
|
||||
- Diff gate: `git diff --check` — PASS.
|
||||
- Exact-head reviews: `codex-code-review.sh --uncommitted` — APPROVE; `codex-security-review.sh --uncommitted` — no findings.
|
||||
- Delivery: committed as `9a9ad1a`, pushed after `ci-queue-wait.sh --purpose push`, and opened PR [#770](https://git.mosaicstack.dev/mosaicstack/stack/pulls/770) to `main` with `Refs #758`. `pr-ci-wait.sh -n 770` reported terminal-green Woodpecker pipeline [#1823](https://ci.mosaicstack.dev/repos/47/pipeline/1823/1).
|
||||
@@ -1,46 +0,0 @@
|
||||
# FCM-M2-002 — Generation-Guarded Fleet Agent CRUD
|
||||
|
||||
- **Task / issue:** FCM-M2-002 / #758
|
||||
- **Branch / base:** `feat/758-fleet-agent-crud` from `origin/main` `191efaefeb5c0c6bb218c1292d12ce8e73ace12b`
|
||||
- **Budget:** 30K card allocation; no deployment or live-fleet actions.
|
||||
|
||||
## Objective
|
||||
|
||||
Provide local roster-owned create, get, update, and delete mutations with a generation precondition, deterministic dry-run plan, complete-state structural/semantic/projection validation before writes, atomic roster persistence, and redacted recovery output on a late projection failure.
|
||||
|
||||
## Scope and exclusions
|
||||
|
||||
- Roster v2 is the sole desired-state authority. Reuse `parseRosterV2`, `renderRosterV2Yaml`, `validateRosterV2Semantics`, and the generated-environment boundary.
|
||||
- Fresh create defaults to `enabled: true` and `desired_state: stopped`; this card never starts a runtime.
|
||||
- Excluded: reconcile/apply; lifecycle/session/systemd/tmux actions; migration/canary; remote/connector/gateway mutation; arbitrary commands/channels/secrets; generated files as authority; `docs/TASKS.md` and orchestration ledger changes.
|
||||
|
||||
## Red-first plan
|
||||
|
||||
1. Add failing tests for dry-run non-mutation, stale generation, concurrent writer locking, stopped default create, equivalent idempotency, complete proposed-state semantic/boundary validation, atomic roster write, and injected late projection failure with redacted recovery details.
|
||||
2. Implement only a roster-v2 CRUD service and file adapter; no legacy `fleet add/remove` behavior expansion.
|
||||
3. Add operator/reference docs for JSON outcomes, generation retries, recovery, and no-runtime-action boundary.
|
||||
|
||||
## Progress
|
||||
|
||||
- Preflight: clean exact base and no duplicate PR confirmed.
|
||||
- Intake read: FCM PRD/AC-FCM-03, task row, launch and generated-env boundaries, roster v2/resolver contracts, legacy fleet command behavior, delivery/QA/TypeScript/security/documentation guidance.
|
||||
- TDD: RED observed for the missing CRUD module. GREEN: focused suite passes 7 tests covering stopped-default create, stale generation, idempotency, dry-run non-mutation, concurrent lock denial, exact stale/absent generated-projection delete cleanup, and redacted late-projection recovery.
|
||||
- REVIEW-1 remediation: RED observed for absent CLI create/get/update/delete/plan wiring. Added roster-v2-only JSON commands, including safe `get`, read-only planning/dry-run, explicit persisted-start recording (never runtime start), stable handled error codes, and focused CLI coverage.
|
||||
- REVIEW-2 remediation: RED observed for missing direct fleet-control-plane registration and ambiguous partial late-I/O result. The public surface is now direct `mosaic fleet {create,get,update,delete,plan}` (not root gateway `mosaic agent`); a late filesystem projection failure returns non-zero redacted JSON with `authoritativeRoster: committed` and `projections: incomplete`, proving no rollback/no-op claim.
|
||||
- REVIEW-3 remediation: RED observed for unnamed update/delete plans and deleted-agent quarantine conflict. `plan <operation> [name]` now requires target names only for update/delete; delete validates/removes only exact generated state while retaining local/legacy/quarantine/unrelated files. Actual CLI/filesystem tests cover create/update/delete plans, plan validation/non-mutation, retained artifacts/dry-run bytes, unsafe permission/symlink rejection, and post-roster delete recovery. Added the required operator how-to.
|
||||
- REVIEW-4 remediation: RED observed that actual Commander create accepted and silently dropped disallowed `command`, `channel`, and `secretRef` keys. The `--agent` object and nested `launch` now use strict own-property allowlists; non-plain/prototype-sensitive shapes and unknown keys fail `invalid-request` before resolver, roster, or projection mutation. Actual Commander plan/create/update tests cover top-level command/channel/secretRef/constructor/prototype/`__proto__` shapes and nested launch unknown fields, byte-identical retained artifacts, non-zero exit, and diagnostics that never echo rejected values. Docs state the same boundary.
|
||||
|
||||
## Risks / assumptions
|
||||
|
||||
- **ASSUMPTION:** M2 mutations operate exclusively on the existing v2 roster contract because generation/lifecycle fields are v2-only; legacy v1 add/remove commands remain unchanged compatibility paths.
|
||||
- A multi-file roster/projection write cannot be one filesystem rename. The roster is authoritative; a post-roster projection failure returns a redacted recovery plan naming only safe paths/actions, never environment values.
|
||||
|
||||
## Verification evidence
|
||||
|
||||
- `pnpm --filter @mosaicstack/mosaic test -- fleet-agent-crud-command.spec.ts fleet-agent-crud.spec.ts generated-env-boundary.spec.ts` — PASS (48 tests after REVIEW-4 remediation).
|
||||
- `pnpm --filter @mosaicstack/mosaic test` — PASS (54 files, 794 tests after REVIEW-4 remediation).
|
||||
- `pnpm --filter @mosaicstack/mosaic lint` — PASS.
|
||||
- `pnpm --filter @mosaicstack/mosaic typecheck` — PASS.
|
||||
- `pnpm format:check` and `git diff --check` — PASS.
|
||||
- Root `pnpm typecheck`, `pnpm lint`, `pnpm format:check`, and `git diff --check` — PASS after REVIEW-4 remediation; full package test is 54 files / 794 tests.
|
||||
- REVIEW-4 remediation focused checks are green; fresh full-delta independent review is required before author-green. No commit, push, or PR opened.
|
||||
@@ -1,39 +0,0 @@
|
||||
# FCM-M3-001 — Local roster-owned reconciliation and lifecycle
|
||||
|
||||
- **Task / issue:** FCM-M3-001 / #758
|
||||
- **Branch / base:** `feat/758-local-reconciler` from `origin/main` `bc5e73629e92c56a80fa6a769ebad17c0177f504`
|
||||
- **Base tree:** `1b9ebe4fa1a90734b6f81118e120bae5290cd350`
|
||||
- **Scope:** source, isolated fake-adapter tests, and card documentation only. No live fleet/systemd/tmux action.
|
||||
|
||||
## Objective
|
||||
|
||||
Provide local roster-v2 `apply`/`reconcile` and lifecycle/status contracts. The roster remains desired-state authority; projections and runtime observations are derived state.
|
||||
|
||||
## Red-first evidence
|
||||
|
||||
The initial focused reconciler test failed because `fleet-reconciler.ts` did not exist. The initial new-worktree test invocation also exposed absent dependencies; `pnpm install --frozen-lockfile --store-dir /home/jarvis/.local/share/pnpm/store` restored local workspace dependencies without changing source.
|
||||
|
||||
## Design
|
||||
|
||||
- A new `fleet-reconciler.ts` accepts only typed roster-v2 input plus injected command and projection adapters.
|
||||
- It targets only exact `mosaic-agent@<roster-name>.service` units and the exact configured tmux socket/session.
|
||||
- It classifies unowned/unmanaged state and fails mutation closed rather than adopting or killing it.
|
||||
- It validates the private install-derived holder identity and complete expected tmux global environment before mutating lifecycle state.
|
||||
- REVIEW-1 remediation: RED review evidence found service-level apply could omit generation and had no mutation lock. Every non-observational command now requires an expected generation; a private exclusive roster-adjacent lock is acquired before effects and released on success or partial failure. Tests cover missing/stale values, concurrent denial, no effects, release, and lock-free observation.
|
||||
- REVIEW-2 remediation: lock acquisition now validates private real `MOSAIC_HOME`/`fleet` ancestors, rejects symlink or unsafe leaves, distinguishes `EEXIST` concurrency from other I/O, and binds release to the created inode plus random ownership token. A replacement lock is retained and reported, not unlinked. A crash may leave a stale lock for inspection; no stale-lock break is claimed.
|
||||
- REVIEW-3 remediation: a lock cleanup failure now adds bounded `cleanup` diagnostics to a known successful or partial effect result without replacing its projection/lifecycle/recovery truth. Cleanup is not claimed as complete, and the retained lock requires inspection before retry.
|
||||
- REVIEW-4 remediation: command JSON with an additive cleanup diagnostic now exits non-zero even where known effects completed; clean effect and observational JSON remain zero-exit.
|
||||
- REVIEW-5 remediation: mutating operations acquire the private lock before rereading canonical `roster.yaml`; the fenced reread, not a caller snapshot, supplies generation validation, plan, projection, and lifecycle authority.
|
||||
- `apply` starts only enabled agents whose persisted desired state is `running`; stopped/default agents are never started by reconciliation.
|
||||
- Observational commands produce JSON classification only. Partial projection or lifecycle effects report explicit recovery without values.
|
||||
|
||||
## Boundaries
|
||||
|
||||
Excluded: live host actions, remote/SSH/connector lifecycle mutation, migrations, canaries, deployment, gateway changes, arbitrary command/channel/secret inputs, `docs/TASKS.md`, and orchestration ledgers.
|
||||
|
||||
## Verification
|
||||
|
||||
- Focused reconciler/Commander/CRUD/fleet tests: 4 files / 229 tests passed.
|
||||
- Full `@mosaicstack/mosaic` suite: 56 files / 820 tests passed after REVIEW-5 canonical roster fencing remediation.
|
||||
- Package and root typecheck/lint, root format check, and `git diff --check`: passed.
|
||||
- Isolated launcher and systemd template harnesses passed; they use fixtures only. No live fleet, systemd, tmux, session, remote, connector, or runtime action occurred.
|
||||
@@ -1,84 +0,0 @@
|
||||
# FCM-M3-002 — Reconciler lifecycle acceptance gates
|
||||
|
||||
- **Task / issue:** FCM-M3-002 / mosaicstack/stack#758
|
||||
- **Branch:** `test/758-reconciler-lifecycle-gates`
|
||||
- **Required starting head:** `499090508ef1d768660e4d54e7934cbcf13cb1cd`
|
||||
- **Required starting tree:** `2f1bb7fed48291f3f7ba8b21c2b52491aa14fe2b`
|
||||
- **Scope:** isolated acceptance coverage and card-required evidence/tracking only; no live fleet, systemd, tmux, session, site, migration, canary, deployment, runtime, connector, or remote action.
|
||||
- **Budget:** use the task estimate of 25K as the working cap; keep the delta to one coherent acceptance suite plus required task/scratchpad evidence. No production change unless a failing reproducer proves an in-scope defect.
|
||||
|
||||
## Intake evidence
|
||||
|
||||
- Clean exact local branch/head/tree verified before editing.
|
||||
- `origin/test/758-reconciler-lifecycle-gates` fetched and verified at the same required head.
|
||||
- The Mosaic PR wrapper reported no open pull requests, so no open-PR branch collision exists.
|
||||
- Parent issue #758 is open and remains intentionally open through M5.
|
||||
- Requirements loaded from `docs/PRD.md` FCM requirements and `AC-FCM-05`, `docs/TASKS.md` FCM DAG, the M3 rows in `docs/fleet/FLEET-CONFIG-DOCS-IA-CHECKLIST.md`, and the FCM-M3-001 implementation scratchpad.
|
||||
|
||||
## Objective
|
||||
|
||||
Add broad, behavior-oriented acceptance evidence around the shipped local reconciler contracts. Exercise only injected fake systemd/tmux adapters and temporary filesystem fixtures. Prove exact ownership/targeting, persisted stopped-state safety, truthful partial-failure recovery, rollback behavior, and stable command JSON/exit outcomes without touching live services or sessions.
|
||||
|
||||
## Acceptance mapping and evidence
|
||||
|
||||
| FCM-M3-002 acceptance concern | Delivered isolated evidence |
|
||||
| --- | --- |
|
||||
| Systemd/tmux lifecycle | `fleet-reconciler.acceptance.spec.ts` drives apply, reconcile, stop, restart, status, and recovery reconcile through one stateful injected fake host. The fake models exact systemd effects and tmux session observations; no host commands run. |
|
||||
| Drift | Canonical roster-v2 YAML drives the Commander `fleet status` boundary and classifies `missing-session`, `unexpected-session`, and `disabled-running`, including combined drift, while asserting observation emits no lifecycle mutation. |
|
||||
| Exact default/named socket targeting | Canonical v2 requires an explicit non-empty named socket; the parser rejects missing/empty values and the Commander acceptance path asserts exact `-L mosaic-fleet` targeting. A separate canonical legacy-v1 roster loader plus runtime-transport path proves a socket-less compatibility roster targets the literal tmux default server with no `-L`. No unreachable empty-socket v2 fixture is used. |
|
||||
| Unmanaged-session classification | Stateful fixtures report sorted `coder0-shadow`/`unmanaged` sessions, then prove an exact roster stop leaves both sessions and the near-collision service intact. |
|
||||
| Crash/partial failure | Injected restart failure is applied after the fake effect to model crash/partial truth: result is `lifecycle: incomplete`, the roster is unchanged, and observed runtime may be active. |
|
||||
| Rollback/recovery semantics | M3 has no rollback command and explicitly does not claim automatic rollback. The acceptance workflow proves the bounded recovery contract: inspect, then exact reconcile restores the persisted stopped target without a start or fuzzy effect. M4 migration/canary rollback remains outside this card. |
|
||||
| Stopped-state preservation | Stateful apply and reconcile both stop an initially running observed agent whose persisted target is stopped; failed explicit restart leaves desired state stopped; recovery reconcile restores stopped state. No start call is emitted. |
|
||||
| Zero fuzzy destructive targeting | Near-collision `coder0-shadow` service/session plus `unmanaged` session remain untouched. The recorded destructive calls contain only exact `mosaic-agent@coder0.service`; no tmux kill action is emitted. |
|
||||
| Stable JSON/exit behavior | Temporary canonical roster fixture invokes the CLI boundary and asserts exactly one JSON line, exact partial-result shape, and exit code 1. Existing focused command specs continue to cover clean zero-exit and stable error JSON. |
|
||||
| Redacted truthful recovery | Fake stderr includes `PASSWORD=acceptance-secret`; exact CLI JSON contains only bounded recovery metadata and excludes the key, value, and raw diagnostic. |
|
||||
|
||||
## Plan
|
||||
|
||||
1. Inventory existing reconciler and command specs against the table above; avoid duplicating narrow assertions already present.
|
||||
2. Add one acceptance-level spec using only fake/injected adapters and temporary files.
|
||||
3. If a real defect is exposed, preserve the failing reproducer and make only the smallest FCM-M3-002-required fix; otherwise leave production unchanged.
|
||||
4. Reconcile `docs/TASKS.md` only for delivered M1/M2/M3-001 truth and mark FCM-M3-002 in progress.
|
||||
5. Run focused tests, full `@mosaicstack/mosaic` tests, package/root typecheck and lint, Prettier/format and diff checks, plus adversarial fake-runner cases.
|
||||
6. Record exact evidence and leave the tree uncommitted for independent synthetic-tree review.
|
||||
|
||||
## TDD decision
|
||||
|
||||
This card adds acceptance coverage to already-delivered behavior. Test-first applies to any product defect discovered: retain a failing reproducer before an in-scope fix. If the shipped behavior already satisfies the acceptance contract, no production code will be changed and the acceptance suite itself is the deliverable.
|
||||
|
||||
## Progress
|
||||
|
||||
- Intake and immutable baseline verification complete.
|
||||
- Existing coverage inventory confirmed strong unit coverage but no stateful cross-command lifecycle acceptance harness.
|
||||
- Added `packages/mosaic/src/fleet/fleet-reconciler.acceptance.spec.ts`: one injected stateful fake systemd/tmux host, temporary canonical v2 and legacy-v1 roster fixtures, and seven acceptance tests.
|
||||
- Production source is unchanged; no product defect requiring an FCM-M3-002 fix was found.
|
||||
- `docs/TASKS.md` reconciles only merged M1/M2/M3-001 truth and marks FCM-M3-002 in progress.
|
||||
|
||||
## Verification evidence
|
||||
|
||||
All commands ran from `/home/jarvis/src/mosaic-stack-local-reconciler` and passed unless explicitly noted.
|
||||
|
||||
- `pnpm --filter @mosaicstack/mosaic exec vitest run src/fleet/fleet-reconciler.acceptance.spec.ts` — final remediation run: 1 file, 7 tests passed; canonical v2 named-socket parsing/Commander status, missing/empty v2 rejection, and canonical legacy-v1 default-server runtime targeting are distinct reachable cases.
|
||||
- Focused reconciler/roster/transport command covering acceptance, reconciler, command, CRUD, v2 parser, and runtime transport specs — 8 files, 304 tests passed.
|
||||
- `pnpm --filter @mosaicstack/mosaic test` — final remediation run: 57 files, 827 tests passed.
|
||||
- `pnpm --filter @mosaicstack/mosaic typecheck` — passed.
|
||||
- `pnpm --filter @mosaicstack/mosaic lint` — passed.
|
||||
- `pnpm typecheck` — 42/42 Turbo tasks successful.
|
||||
- `pnpm lint` — 23/23 Turbo tasks successful.
|
||||
- `pnpm exec prettier --check docs/TASKS.md docs/scratchpads/758-fcm-m3-002-reconciler-lifecycle-gates.md packages/mosaic/src/fleet/fleet-reconciler.acceptance.spec.ts` — passed.
|
||||
- `pnpm format:check` — all matched files use Prettier style.
|
||||
- `git diff --check` — passed with no output.
|
||||
- Initial scoped Prettier check found style drift in the new spec and tracking table; `pnpm exec prettier --write ...` remediated it before all final gates above.
|
||||
- No live fleet, systemctl, tmux, process, site, migration, canary, deploy, runtime, connector, or remote command was invoked.
|
||||
|
||||
## Review boundary
|
||||
|
||||
This is an author handoff. No self-review is represented as reviewer-of-record. The uncommitted synthetic tree is intended for independent review.
|
||||
|
||||
## Risks / blockers
|
||||
|
||||
- M3 truthfully reports incomplete lifecycle effects and bounded recovery; it does not implement or claim an automatic rollback command. This suite proves stopped-state restoration by the documented exact recovery reconcile. M4 retains migration/canary rollback ownership.
|
||||
- The fake host models only the public systemd/tmux runner contract and temporary roster filesystem boundary. This is intentional under the no-live-effects hold.
|
||||
- Parent issue closure, commit, push, PR, merge, deployment, and branch cleanup remain explicit holds.
|
||||
- No residual implementation blocker.
|
||||
52
docs/scratchpads/769-kbn100-schema-migration.md
Normal file
52
docs/scratchpads/769-kbn100-schema-migration.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# Issue #769 — KBN-100 unified schema and N-1 migration
|
||||
|
||||
## Status
|
||||
|
||||
IN PROGRESS — tracking and baseline gate. No schema or migration implementation is authorized until this scratchpad and `docs/native-kanban-sot/TASKS.md` are committed/pushed and baseline evidence is recorded.
|
||||
|
||||
## Requirement authority
|
||||
|
||||
- Provider issue: <https://git.mosaicstack.dev/mosaicstack/stack/issues/769>
|
||||
- Canonical requirements: `docs/requirements/native-kanban-sot.md`
|
||||
- Frozen integration contract: `docs/native-kanban-sot/SHARED-CONTRACT.md` v1.0.0-rc.4
|
||||
- Frozen target schema: `docs/native-kanban-sot/contracts/kanban-schema.v1.ts`
|
||||
- Threat/auth/constraint gate: `docs/native-kanban-sot/KBN-010-THREAT-AUTH-CONSTRAINT-GATE.md`
|
||||
- Dependency evidence: PR #765 merge `2e228007`, issue #753 closed, post-main pipeline #1813 terminal success.
|
||||
|
||||
## Fixed decisions
|
||||
|
||||
1. PostgreSQL is the sole writable project/task/orchestration SOT; generated projections and external transports are never import/write authority.
|
||||
2. Expand is additive and N-1-safe. Do not drop, rename, narrow, or reinterpret legacy data during this card.
|
||||
3. Backfill is bounded, idempotent, resumable, checksummed, and quarantines ambiguity rather than guessing.
|
||||
4. rc.4 SI-001 creates `missions_workspace_id_uidx(workspace_id,id)` before both dependent artifact/approval FKs while retaining global and project-congruent keys.
|
||||
5. Runtime repositories, Gateway/MCP, Coordinator behavior, clients, fleet, channels, connector fencing, deployment, and production migration execution are out of scope.
|
||||
6. Critical schema/data behavior uses TDD RED/GREEN and real PostgreSQL integration evidence; PGlite-only evidence cannot certify the migration.
|
||||
|
||||
## Authorized paths
|
||||
|
||||
- `packages/db/src/schema.ts`
|
||||
- `packages/db/drizzle/**`
|
||||
- DB-package tests/config files explicitly enumerated by the author before first edit
|
||||
- `docs/native-kanban-sot/TASKS.md`
|
||||
- this scratchpad
|
||||
|
||||
Any additional path requires control-plane approval before edit.
|
||||
|
||||
## Delivery cycle
|
||||
|
||||
`baseline -> test design/RED -> schema+migration GREEN -> focused tests -> real PostgreSQL migration matrix -> full gates -> independent DB review -> SecReview -> remediate/rereview -> Codex-Ultron -> PR CI -> squash merge -> post-main CI -> issue close`
|
||||
|
||||
## Baseline evidence
|
||||
|
||||
Pending fresh author-lane intake and baseline test run.
|
||||
|
||||
## Collision boundaries
|
||||
|
||||
- #758 owns generic local Fleet configuration/lifecycle cards.
|
||||
- #757/#755 owns logical-agent connector identity/lease/fencing.
|
||||
- #756 channel foundation is merged and remains separate.
|
||||
- KBN-105 and all KBN consumers remain held until #769 completes.
|
||||
|
||||
## Completion evidence
|
||||
|
||||
Pending implementation, reviews, PR, merge, terminal-green main CI, and issue closure.
|
||||
@@ -1,183 +0,0 @@
|
||||
# Scratchpad — KBN-101 DB runtime/migration role split (#771)
|
||||
|
||||
- **Branch:** `docs/771-kbn101-db-role-split`
|
||||
- **Base:** `main` `e9c4aa3`
|
||||
- **Scope:** planning/documentation only; authorized files are PRD, Native Kanban task/shared/index docs, sitemap, this scratchpad, and the new KBN-101 contract.
|
||||
- **Explicit exclusions:** source/runtime/config/deployment/secret/migration/compose/CI/lock/package/KBN-100 branch edits; no production mutation.
|
||||
|
||||
## Objective
|
||||
|
||||
Freeze an implementation-ready PostgreSQL role/connection split so the Gateway uses a non-owner runtime identity and only a dedicated migration phase uses an owner/migrator identity. Make real deployed-role certification—not synthetic role tests—a serial prerequisite of KBN-100 and KBN-105.
|
||||
|
||||
## Intake and current-state evidence
|
||||
|
||||
- Mission MVP is active; W3 Native Kanban/SOT is planning-complete. The task state shows KBN-010 as the predecessor and KBN-100 as the current schema slice.
|
||||
- Current branch started at `e9c4aa3`; `.mosaic/orchestrator/{mission.json,session.lock}` were already runtime-modified and remain untouched.
|
||||
- `packages/db/src/client.ts`, `migrate.ts`, and `drizzle.config.ts` resolve one `DATABASE_URL` (with default fallback). `packages/storage/src/adapters/postgres.ts` calls `runMigrations(this.url)`.
|
||||
- `apps/gateway/src/database/database.module.ts` calls `storageAdapter.migrate()` at startup for PostgreSQL; this is the owner-runtime defect to remove in KBN-101 implementation.
|
||||
- `packages/config/src/mosaic-config.ts`, installer wizard, local/federated compose, Portainer test stack, and `.woodpecker/ci.yml` currently expose one URL. PGlite has an existing explicit local migration path.
|
||||
- Current KBN contract requires immutable events/checkpoints/artifacts/evidence, `RESTRICT`, and KBN-100 generated Drizzle consistency. It did not establish a deployable runtime identity split.
|
||||
|
||||
## Frozen decisions
|
||||
|
||||
1. `DATABASE_URL` is the non-owner runtime URL; `DATABASE_MIGRATION_URL` is migration-only. Both are required in their respective PostgreSQL phases; PGlite is the explicit local exception; migration never falls back to runtime/default/config URL.
|
||||
2. PostgreSQL Gateway runtime never auto-runs migration/DDL. Dedicated migrator uses `pg_try_advisory_lock(hashtext('mosaic-schema-migration-v1'))`; replicas only check exact ordered Drizzle-ledger readiness and fail closed.
|
||||
3. Roles are non-login `mosaic_platform_database_owner`, non-login `mosaic_schema_owner`, login/noinherit `mosaic_migrator`, non-login `mosaic_runtime_capability`, and login/inherit `mosaic_runtime`. Runtime inherits only its capability role with SET/ADMIN denied, has no owner/migrator membership, no unsafe attributes/ownership/DDL authority, and an explicit trusted search path.
|
||||
4. Runtime gets mutable DML only as needed, but INSERT/SELECT only on `task_events`, `artifacts`, `task_checkpoints`, `task_checkpoint_artifacts`, and `approval_decision_artifacts`. KBN-100 still enforces RESTRICT/no-cascade.
|
||||
5. Startup verifies effective role/ownership/attributes/inherited capability/TEMP/function-execute/ledger grants/search path/immutable denials/schema fingerprint without DSN exposure. It also requires authenticated CA/hostname-verified TLS. Stable sanitized errors and redaction rules are required.
|
||||
6. N-1 retains single runtime URL only as a non-certified compatibility release; staged role provisioning/migration/runtime deployment then enforces the split. Rollback never injects migration URL into Gateway.
|
||||
7. Vault target paths, rotation, deployment injection, CI, installer, compose, Portainer, and observability are separate one-card/one-PR handoffs. The migration-only file manifest includes `packages/db/drizzle.config.ts`; KBN-101 repairs the known PostgreSQL runner/journal ordering defect and proves a clean database applies every hash once before its foundation certificate. No application migration creates roles/passwords or hardcodes credentials.
|
||||
8. KBN-101 foundation merges/certifies first. KBN-100 then rebases, restores Drizzle declaration/snapshot/journal consistency, and bounds procedural immutable-table grant/trigger/backfill work to its own slice. Because those immutable relations do not exist until KBN-100, KBN-101’s real deployed-role immutable-operation certificate follows KBN-100 and is the serial gate before KBN-105.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- `standalone` and `federated` are all current PostgreSQL production-like modes; a future PostgreSQL tier inherits this contract unless versioned otherwise.
|
||||
- Deployment will support a dedicated migration Job/one-shot command. A target that cannot run it cannot receive production/federated KBN certification.
|
||||
- Canonical Vault target paths require deployment-owner verification before provisioning; the planning document does not claim they already exist.
|
||||
|
||||
## Documentation produced
|
||||
|
||||
- `docs/PRD.md`: bounded KBN-101 requirements and acceptance criteria.
|
||||
- `docs/native-kanban-sot/KBN-101-DB-ROLE-SPLIT.md`: normative rc.5 implementation, threat, migration/rollback, evidence, and exact file DAG contract.
|
||||
- `docs/native-kanban-sot/SHARED-CONTRACT.md`: rc.5 amendment preserving rc.4.
|
||||
- `docs/native-kanban-sot/TASKS.md`: KBN-101 inserted before and blocks KBN-100; KBN-105 held.
|
||||
- Native Kanban index and root sitemap links.
|
||||
|
||||
## Validation plan
|
||||
|
||||
1. Prettier only for changed Markdown.
|
||||
2. Markdown link target/check checks scoped to modified docs.
|
||||
3. Strict contract check with `pnpm exec tsc --noEmit -p docs/native-kanban-sot/tsconfig.json` (the frozen TypeScript contracts remain unchanged).
|
||||
4. Diff allowlist proves only authorized documentation files changed, apart from pre-existing Mosaic runtime state.
|
||||
5. Independent documentation/security self-review: role escalation, fallback, startup DDL, schema readiness, grants/default privileges, immutable tables, secret leakage, deployment and KBN-100 boundaries.
|
||||
|
||||
## Review corrections
|
||||
|
||||
Independent Codex review found two blockers and security review found two medium defects; all were remediated in the frozen contract:
|
||||
|
||||
1. Split the KBN-101 certificate into a foundation role/schema-boundary certificate (before KBN-100) and real immutable-operation certificate (after KBN-100, before KBN-105). This preserves the requested KBN-100 block without requiring evidence for tables not yet created.
|
||||
2. Removed the legacy owner-runtime exception. N-1 compatibility preserves variable/config shape only; the KBN-101 runtime refuses owner/migrator identity and current single-URL installs remain on their previous release until role cutover.
|
||||
3. Introduced `mosaic_platform_database_owner` as a separate non-login platform role. `mosaic_schema_owner` owns application/ledger schemas only, not the database and has no database CREATE/ALTER/extension authority.
|
||||
4. Replaced blocking `pg_advisory_lock` with `pg_try_advisory_lock` and the deterministic `DATABASE_MIGRATION_LOCKED` failure.
|
||||
5. Review also flagged active `.mosaic/orchestrator` state. It was pre-existing launcher state and remains unstaged/uncommitted.
|
||||
6. Second review added `packages/db/drizzle.config.ts` to the migration-only slice, mandates `DATABASE_MIGRATION_URL` with a missing-variable negative, grants runtime only `USAGE` plus `SELECT` on `drizzle.__drizzle_migrations`, and verifies/revokes its ledger writes.
|
||||
7. Security review added `DATABASE_TLS_CA_CERT_PATH` / `DatabaseTlsConfigDto` with authenticated TLS and hostname/CA verification in production-like modes, explicit database `TEMPORARY` revocation/catalog denial testing, and default-PUBLIC function EXECUTE revocation with SECURITY DEFINER prohibited by default.
|
||||
8. Final review corrected the runtime login to inherit only its capability role with SET/ADMIN denied, and moved the known hash-complete migration-runner/journal repair into KBN-101-03 before the foundation certificate.
|
||||
9. Final manifest review added all live runtime DDL paths (`packages/storage/src/tier-detection.ts`, Gateway startup, and `fleet-backlog`) to KBN-101-02, requiring read-only extension probes and no PostgreSQL runtime auto-migration. It also requires KBN-101-04 to stop persisting either DSN into generated `.env`/`mosaic.config.json`, using only Vault/deployment references and injected variables.
|
||||
10. Provisioning review separated the external privileged platform bootstrap actor from the NOCREATEDB database-owner role and added KBN-101-00. That IaC/bootstrap card owns fresh/existing database role/ownership/grant/Vault transition evidence and is a foundation-certificate dependency.
|
||||
|
||||
## Results
|
||||
|
||||
- `pnpm exec prettier --check` on every authorized Markdown file: PASS.
|
||||
- Markdown link and whitespace checker on all seven authorized Markdown files: PASS.
|
||||
- `pnpm exec tsc --noEmit -p docs/native-kanban-sot/tsconfig.json`: PASS (frozen strict contracts unchanged).
|
||||
- Codex code review iterated through role inheritability, hash-complete migration ordering, all reachable runtime DDL entrypoints, installer DSN persistence, and platform-bootstrap ownership; each finding was incorporated into the final frozen contract/DAG. The last security review found no new KBN-101 vulnerability; its sole low finding is the pre-existing unstaged Mosaic session-lock metadata, which is excluded from this commit.
|
||||
- Commit: `82ce3252df38a687c50485f8d048b53ca8db5989` (`docs(#771): freeze database runtime role split`).
|
||||
- Pre-push queue guard: `ci-queue-wait.sh --purpose push -B main` returned `state=unknown` without failure. The push hook ran repository `pnpm typecheck`, `pnpm lint`, and `pnpm format:check`: PASS.
|
||||
- Pushed branch `docs/771-kbn101-db-role-split` at the exact commit above; no PR was opened, merged, or closed. `web1:mosaic-100` received the handoff with head, decisions, DAG, and validation.
|
||||
- Awaiting independent security/Ultron review.
|
||||
|
||||
## 2026-07-15 — rc.6 exact-head remediation session
|
||||
|
||||
- **Objective / correction:** Replace the prior planning-author handoff and close every finding in the [independent exact-head report](../reports/native-kanban-sot/kbn-101-contract-security-review-82ce325.md) against `da742ca2da4a2ff466916c818fe275c4f7ffd384`. The report is a verbatim durable copy of the task-supplied review artifact; scope remains documentation-only, `.mosaic` is excluded, and source/config/compose/CI/deployment/secrets/migrations remain untouched.
|
||||
- **Finding 1 — closed DDL control plane:** rc.6 names `mosaic-db-migrator` as the sole application/CI/test PostgreSQL DDL runner, requires `DATABASE_MIGRATION_URL` before connection/DDL, inventories `runMigrations`, Drizzle config/scripts, `db:push`, storage CLI, adapter/Gateway startup, fleet-backlog, extension probes/bootstrap, direct federated integration DDL, CI, and future scripts, and specifies route/deny/test disposition for each. Tests use runner-prepared disposable PostgreSQL or invoke that runner; `db:push` is local-disposable-only and rejects production-like URLs.
|
||||
- **Finding 2 — deployable TLS:** rc.6 freezes distinct runtime/migrator URL and CA/server leaf Vault/compose/Swarm secret identifiers, `0400` key and `0600` URL/cert/CA mount requirements, actual compose/Swarm service-DNS SANs, PostgreSQL TLS settings, legacy-client drain/termination plus `hostssl` enforcement, verified-TLS readiness ordering, fresh/existing transition, CA-overlap rotation/TLS-only rollback, and standalone plus federated/Swarm positive and missing/wrong CA/SAN/downgrade negatives. PGlite is explicitly non-PostgreSQL evidence.
|
||||
- **Finding 3 — manifest/0009:** rc.6 defines manifest v1 canonical UTF-8 serialization and raw SQL-byte SHA-256, logical journal order, manifest ownership/grants, exact one-to-one observed hash tuple mapping, non-normative physical insertion order, safe original-0009 conditions, ambiguous-effect fail-closed recovery, and the full required reconciliation/backup test matrix. It preserves shipped 0009 bytes and forbids manual ledger adoption/insertion.
|
||||
- **Finding 4 — advisory lock:** replaced `hashtext` with fixed signed-int4-safe `(1297044289,1262636593)` (`MOSA`,`KBN1`), one `max:1` runner session, close-on-crash semantics, and contention/crash/readiness/unrelated-key evidence.
|
||||
- **Finding 5 — identifiers/search path:** selects `mosaic`, exact `pg_catalog,mosaic` per pooled connection and `SET LOCAL` transactions, plans audited public-object/extension/Drizzle relocation, forbids config-derived identifiers, limits bootstrap quoting to server-side `%I` on fixed allowlist, and requires injection/pool-reset negatives.
|
||||
- **Finding 6 — safe DAG:** cards 00–07 are inactive prepared capability while owner-runtime remains N-1; KBN-101-08 is the one atomic activation release after platform roles/TLS and compatible code. Mosaic control plane/Jason alone can activate/rollback; no force-on-red, runtime bypass, or temporary compatibility survives the gate. The approved role graph, post-KBN-100 immutable certification, and KBN-105 gate remain unchanged.
|
||||
- **Review remediation:** Codex review found the legacy plaintext cutover gap, missing URL-secret bindings, historical `public` migration incompatibility, non-reproducible checkout-byte hashing, CONNECT allowlisting regression, and undocumented direct-DDL operator instructions. rc.6 now requires drain/scale-to-zero, residual non-TLS session termination, `hostssl` with no `host` rule, zero plaintext-session proof, TLS-only post-enforcement rollback, distinct named runtime/migrator secret consumers, canonical Git-blob/LF manifest bytes, a runner-only owner-controlled legacy-public bootstrap followed by `mosaic` relocation, explicit CONNECT/TEMP revocation, and KBN-101-07 replacement of direct-DDL documentation. It also required the durable exact-head report link above. Pre-existing `.mosaic` runtime state remains excluded.
|
||||
- **Validation:** Prettier on all changed Markdown, repository Markdown link/whitespace check, and strict native-kanban contract TypeScript passed before final staging; the final staged diff excludes `.mosaic`. No source-code TDD applies because this is contract-only remediation.
|
||||
|
||||
## 2026-07-15 — rc.7 residual remediation session
|
||||
|
||||
- **Objective / correction:** Close every residual in the independent exact-head rc.6 re-review at `/home/hermes/agent-work/reviews/771-kbn101-contract-rereview-45ba3d6.md` for `45ba3d6ad4d5383f457a303c05bc816144cfa48a`, without changing source, compose, CI, deployment, migration, or secret artifacts. Only the existing authorized planning/documentation paths are eligible; pre-existing `.mosaic` state remains excluded.
|
||||
- **Source-backed scope confirmed:** the active `federated-pgvector.integration.test.ts` executes `CREATE TEMP TABLE`; tracked `docker/init-db.sql` and `infra/pg-init/01-extensions.sql` both create `vector`; `migrate-tier.ts` advertises raw `CREATE EXTENSION`; and `tools/federation-harness/docker-compose.two-gateways.yml` is current plaintext two-PostgreSQL/two-Gateway topology. Current `schema.ts` has 36 default-schema `pgTable` declarations, 6 default `pgEnum` declarations, and an unqualified `vector` custom type; historical migrations contain `public` references.
|
||||
- **Plan:** (1) make the finite DDL/static-bypass inventory and `DATABASE_URL`-only denial matrix exact, including the runner-prepared persistent pgvector fixture and migrated two-gateway harness; (2) freeze executable `public`-to-`mosaic` and `mosaic_extensions` transition, Drizzle ownership, object-catalog classes/order, eligibility and rollback tests; (3) bind repository/control-plane ownership, UID/GID validation, exact artifact/mount rules, and both gateway TLS topology; (4) correct PRD acceptance mapping and cross-document rc.7 status; then run formatting, link/contract, source-path, diff, review, commit, queue guard, and push.
|
||||
- **Independent review closure:** initial Codex review found Gateway-key consumer wording, `CLAUDE.md` omission, final schema-owner set, and placeholder SANs; all are now explicit. Re-review found the legacy `0001` vector-type resolution problem and `docs/federation/SETUP.md` raw-DDL instruction; the legacy runner now uses only its fixed non-writable `pg_catalog,public,mosaic_extensions` history path, while runtime remains `pg_catalog,mosaic`, and the federation setup path is assigned to KBN-101-07/static inventory. Security review final verdict: no confident vulnerability. The review also repeated the pre-existing tracked `.mosaic` session-state concern; it remains deliberately unstaged/excluded by this task.
|
||||
- **Completion evidence:** changed Markdown is Prettier-formatted; local links and strict native-kanban TypeScript passed; source-path inventory confirmed all current referenced paths (the new `apps/gateway/Dockerfile` is explicitly a planned KBN-101-05 artifact); diff check and authorized-doc allowlist passed. No source-code TDD applies to this documentation-only remediation.
|
||||
|
||||
## 2026-07-15 — rc.8 exact residual remediation session
|
||||
|
||||
- **Objective / correction:** Close all three HIGH findings in the independent rc.7 exact-head re-review at `/home/hermes/agent-work/reviews/771-kbn101-contract-rereview2-0778eba.md` for `0778eba2db3c2dfbaca3af352b12ba0389d3552b`. Scope remains documentation-only: no source, config, Compose, CI, deployment, secret, or migration artifact changed; pre-existing `.mosaic/orchestrator` state remains excluded.
|
||||
- **Finite authority closure:** KBN-101-06 now classifies exact current source/scripts/package bins, operator docs, and deploy manifests. `packages/db/src/index.ts` has explicit removal/compile-import negative ownership; `docs/fleet/backlog-conventions.md` and `docs/PERFORMANCE.md` now remove first-use/direct-Drizzle/Gateway-startup migration instructions and point to sole runner/readiness. Byte-immutable historical SQL, PGlite-only routines, negative-test literals, vendored/generated artifacts, and labeled historical reports are exact-path/category reviewed allowlists; unknown hits fail. The contract explicitly rejects relying on a naive token scan alone.
|
||||
- **Executable and exclusive handoff closure:** KBN-101-03 exclusively owns the published `mosaic-db-migrator` bin, `packages/db/src/cli.ts`, private migrator modules, `docker/db-migrator.Dockerfile`, exact `--run|--verify|--help`, environment/argv limits, sanitized exits, and command/order tests. KBN-101-00 exclusively owns `infra/pg-bootstrap/roles.sql`, `infra/pg-bootstrap/extensions.sql`, `infra/pg-bootstrap/README.md`, and bootstrap tests. KBN-101-05 exclusively owns `tools/db/render-postgres-secrets.ts`, its tests, and deployment declarations, consuming the versioned bootstrap interface without overlap.
|
||||
- **pgvector owner closure:** `mosaic_extension_owner` is dedicated NOLOGIN, available only to the external bootstrap actor during bootstrap; fresh vector/member ownership remains there. The contract records PostgreSQL's unsupported extension-owner transfer and forbids catalog mutation, ownership adoption, and `DROP CASCADE`. Approved-owner existing extensions use verified `ALTER EXTENSION ... SET SCHEMA`; legacy runtime-owned extensions fail closed to a controlled backup/shadow/runner/copy-evidence/quiesce/final-delta/atomic-switch/read-only-rollback migration. It requires `pg_extension.extowner`, member/schema/version, and runtime/migrator/schema-owner ALTER/DROP/member-update denial tests across clean, approved-owner, legacy shadow, partial/resume/rollback, and N-1.
|
||||
- **Cross-document state:** PRD, KBN contract, shared contract, task decomposition, index, sitemap, current operator docs, and this scratchpad are rc.8-consistent. The only intended next action is a fresh independent exact-head re-review after validation/push.
|
||||
- **Validation / review:** Prettier passed for all nine changed Markdown documents; local links passed (9 documents); `pnpm exec tsc --noEmit -p docs/native-kanban-sot/tsconfig.json` passed; source-path inventory passed (20 paths: 8 current, 12 explicitly planned); finite-authority requirement checklist and `git diff --check` passed. Manual documentation/security review checked the three requested paths, private-only runner boundary/exit contract, non-overlapping 00/03/05 ownership, extension-owner denial and shadow path, and `.mosaic` exclusion. No source-code TDD applies because this is contract-only remediation.
|
||||
- **Delivery evidence:** committed `1423c2ad02b5471eab006fb4c878808e5b29c387` as `docs(#771): close role split rc.8 residuals`. Push queue guard returned `state=unknown` without error; push hook ran repository `pnpm typecheck`, `pnpm lint`, and `pnpm format:check`, all PASS; branch push succeeded. This final evidence append is committed next, then the exact remote head is verified. The only intended next action is a fresh independent exact-head re-review.
|
||||
|
||||
## 2026-07-15 — rc.9 final residual remediation session
|
||||
|
||||
- **Objective / correction:** Close the three findings in `/home/hermes/agent-work/reviews/771-kbn101-contract-rereview3-9cf5d2f.md` against exact head `9cf5d2f6641b14082dc3294e2a84d1fb4ccc019d`: move `mosaic_extensions` schema ownership to `mosaic_extension_owner`; replace all broad/conflicting KBN-101 card ownership with a complete disjoint exact-path/test manifest; and classify the current architecture-plan `db:migrate` instruction with pinned scanner mechanics. Scope remains documentation-only; no source/config/Compose/CI/deployment/secret/migration artifact and no `.mosaic` path may be modified.
|
||||
- **Plan:** inspect current tracked source topology to name only existing paths; update the normative contract first and synchronize PRD/shared/task/index/sitemap/version language; run Prettier, changed-doc links, strict contract TypeScript, source/path and manifest-overlap checks, diff allowlist, independent documentation/security review; then stage docs only, commit, queue-guard, push, and verify exact remote SHA. No source-code TDD applies because this is contract-only remediation.
|
||||
- **Closure implemented:** rc.9 makes `mosaic_extension_owner` create and own `mosaic_extensions`, `vector`, and members; the external bootstrap actor alone temporarily `SET ROLE`s for fresh/approved-owner work, while schema owner has only `USAGE` for legacy type resolution and never temporary `CREATE`. Catalog/default-ACL plus direct DDL/member denials now cover runtime, migrator, and schema owner through fresh, relocation, shadow/resume, and rollback evidence.
|
||||
- **Delivery decomposition:** Replaced broad ownership with complete disjoint 00–09 manifests, exact tests/evidence, producer-before-consumer edges, and an explicit no-intermediate-deploy N-1 activation statement. `packages/storage/src/{cli,migrate-tier}.ts` belongs only to -02; the current tracked init artifacts are retired by -02 as direct-DLL closure; -03 owns all runner/index/migrate/config/schema assets and exact compiled-bin/image mapping; -07 owns docs only; -08/-09 own evidence only.
|
||||
- **Classifier closure:** -06 has exact scanner/inventory/matrix paths, canonical inventory fields, classes/dispositions, fixed token/rule set, exact allowlist categories/restrictions, and self-test requirements for unknown, duplicate-owner, ownerless, missing-path, and historical masking cases. The architecture plan now marks direct `db:migrate` superseded and uses `mosaic-db-migrator --run`.
|
||||
- **Validation:** Prettier check, strict native-kanban contract TypeScript, changed-document local-link resolution, `git diff --check`, and an automated manifest-overlap/owner/current-source-path check passed. Targeted documentation/security review verified role ownership/default privileges/search path/preflight/legacy/shadow/rollback consistency, disjoint manifests/DAG/activation, scanner mechanics, exact bin/entrypoint, and no `.mosaic` staging intent. No source-code TDD applies because this is documentation-only remediation.
|
||||
- **Next:** stage documentation only, commit, queue-guard, push, verify exact remote SHA, then wait for fresh exact-head review.
|
||||
- **Delivery evidence:** committed `8cbad2bcd9bc7507052f74f35670ef7c8e39e44e` as `docs(#771): close role split rc.9 residuals`; `ci-queue-wait.sh --purpose push -B main` returned `state=unknown` without error; push-hook `pnpm typecheck`, `pnpm lint`, and `pnpm format:check` all passed; push succeeded and `origin/docs/771-kbn101-db-role-split` resolved to that exact SHA. Pre-existing `.mosaic/orchestrator/{mission.json,session.lock}` remains modified but intentionally unstaged/excluded. The only next action is a fresh independent exact-head re-review.
|
||||
|
||||
## 2026-07-15 — rc.10 Ultron NO-GO remediation intake
|
||||
|
||||
- **Objective / scope:** Close only HIGH-1 and HIGH-2 in `/home/hermes/agent-work/reviews/771-kbn101-ultron-11f09a1.md` against exact head `11f09a15e43e72afda6a0374668996b4bda9e536`. Documentation only: preserve approved content; no source/config/Compose/CI/deploy/secret/migration edits and no `.mosaic` staging.
|
||||
- **Plan:** (1) replace the impossible `NOLOGIN NOSUPERUSER` extension owner with the exact non-login, zero-member `NOLOGIN SUPERUSER` external-control exception and document the non-delegable superuser residual; (2) require the audited external superuser session to `SET ROLE`/`RESET ROLE` for fresh, approved-owner, and shadow extension work, then prove catalog ownership and all service-role denial; (3) assign the active migrate-tier guide exclusively to KBN-101-07, add its active secure route to the -06 inventory/matrix/scanner schema, and freeze `--target-url-file /run/secrets/mosaic_migrate_target_url` plus pre-migrated target/dedicated non-DDL importer requirements; (4) synchronize PRD/shared/tasks/index/sitemap/guide and rc.10 status; (5) validate formatting, links, contracts, source paths, finite operator inventory, diff, review, commit, queue guard, push, and exact remote SHA.
|
||||
- **Target-image evidence before edits:** local `pgvector/pgvector:pg17` control file reports `default_version = '0.8.2'`, `relocatable = true`, and no `trusted`/`superuser` override (untrusted PostgreSQL extension). An isolated PostgreSQL 17 container proved a `NOLOGIN SUPERUSER` `mosaic_extension_owner` can create `mosaic_extensions` and `vector` under external-superuser `SET ROLE`, returns to the external session after `RESET ROLE`, has `rolcanlogin=false`, `rolsuper=true`, zero role members, exact extension/schema and owner-bearing-member ownership, and denies `SET ROLE`, `ALTER EXTENSION`, `DROP EXTENSION`, and schema ownership changes to runtime, migrator, schema owner, and data importer. Ownerless PostgreSQL catalog member classes (`pg_am`, `pg_cast`) were intentionally not misrepresented as ownable members.
|
||||
- **TDD decision:** skipped as not applicable: this is a documentation-only contract remediation. Future KBN-101-00/-02/-06 tests are specified as the situational evidence; no source/test artifact is permitted in this task.
|
||||
- **Final scope correction:** Per control-plane direction, remediation remains bounded to the two Ultron findings. The active guide is explicitly a non-operative KBN-101 contract until its owned implementation/activation lands; no additional design, source, CI, deployment, secret, or test artifact was added.
|
||||
- **Validation evidence:** target-image/container role proof PASS (pgvector `0.8.2`, `relocatable=true`, trusted absent/untrusted; external-superuser `SET ROLE`/`RESET ROLE`; exact extension/schema/owner-bearing-member ownership; zero membership and service-role denials). Changed-doc Prettier, strict native-kanban contract TypeScript, local-link resolver (8 docs), finite operator-doc inventory (one active KBN-101-07 route with no credential argv in executable blocks), source-path check (6 current paths), and `git diff --check` PASS. Pre-existing `.mosaic/orchestrator/{mission.json,session.lock}` remains intentionally excluded.
|
||||
|
||||
## 2026-07-15 — rc.11 exact-head re-review remediation intake
|
||||
|
||||
- **Objective / scope:** Close only HIGH-1 and HIGH-2 in `/home/hermes/agent-work/reviews/771-kbn101-contract-rereview5-f60144e.md` against `f60144eb3eab6234ab01bda592052081c777897e`: target-bind the non-DDL tier importer with a runner-produced signed attestation, and disposition every current non-normative documentation scanner hit, including the active user-guide route and federation historical status. Documentation only; no source/config/Compose/CI/deployment/secret/migration edits and no `.mosaic` staging.
|
||||
- **Frozen decision:** `mosaic-db-migrator --verify` is trusted only after TLS/identity/manifest/schema verification and signs a credential-free JCS/Ed25519 v1 artifact from a runner-only fixed root-owned private-key file. The artifact binds secret version/exact URL-file digest, canonical TLS/CA/SPKI/server/database/importer/manifest/schema identity, issued/expiry/nonce, and producer build/correlation; importer gets pinned public key plus artifact only. It validates both files and all bindings before target connection, opens/digests/connects from one in-memory URL read, validates server identity before DML, and distinguishes zero connection from zero DML. Key overlap/revocation, secret-rotation invalidation, replay cache, atomic rename, and sanitized errors are mandatory.
|
||||
- **Ownership:** -03 owns producer/signing DTO/tests; -02 importer interface/verification tests; -05 key/artifact mounts/render tests; -06 inventory/matrix and non-masking scanner tests; -07 operator guide. The exact manifests remain disjoint.
|
||||
- **Operator correction:** `storage migrate` is schema-wrapper delegation only; legacy `--from hot --to cold` tier-copy guidance is unavailable. Secure tier copy is `migrate-tier` with `--target-url-file` plus `--target-attestation-file`. Federation M1 task language is status-only and adjacent KBN-101 text says it authorizes no current DDL.
|
||||
- **TDD decision:** skipped as not applicable: this bounded task changes documentation only. Future -02/-03/-05/-06 tests are specified as the required implementation evidence.
|
||||
- **Validation / review:** Prettier PASS on all 18 changed Markdown/root-doc files; `pnpm exec tsc --noEmit -p docs/native-kanban-sot/tsconfig.json` PASS; changed-doc local-link resolver PASS (71 links); full current docs scanner/disposition PASS (10 non-normative paths, 4 normative-contract paths; no unknown active command); legacy `storage migrate --from` and raw target-URL bypass scan PASS; attestation field/interface assertion PASS; exact source ownership/manifest-overlap assertion PASS; `git diff --check`, docs-only scope, and secret-leak scan PASS. Manual documentation/security review verified key isolation, JCS/detached signature, secret-file hash as non-secret evidence, verification ordering/TOCTOU/replay/rotation, no connection vs zero DML, non-masking scanner class, status-only federation history, and no regression to pgvector closure. No source-code TDD applies.
|
||||
- **Delivery evidence:** committed `6227f076c819bd124383851633b16d4ef9c88a98` as `docs(#771): bind tier importer to verified target`; staged scope was 18 documentation files only and excluded `.mosaic`. Pre-push queue guard returned `state=unknown` without failure. Push hook ran repository `pnpm typecheck`, `pnpm lint`, and `pnpm format:check`: PASS. Branch push succeeded. Verify the exact remote SHA after this final delivery-evidence append, then idle for independent exact-head re-review and Ultron reverify. `.mosaic/orchestrator/{mission.json,session.lock}` remains pre-existing and excluded.
|
||||
|
||||
## 2026-07-15 — rc.12 bounded deployable-importer/SETUP remediation plan
|
||||
|
||||
- **Objective / scope:** Close the two HIGH findings in `/home/hermes/agent-work/reviews/771-kbn101-contract-rereview6-65663d4.md` against exact head `65663d4f72f2ace5148bce9aeba04b5a8d5beee9`. Documentation/tracking only; preserve every earlier closure; do not modify source, deployment, Compose, CI, migration, Vault, or `.mosaic` artifacts.
|
||||
- **Plan:** (1) make KBN-101-05 own canonical KV-v2 importer URL/version provenance, separate immutable generation-pinned renderer consumers, importer CA/public-key/attestation mounts, fixed importer/migrator identities, safe-fd lifecycle, isolation/rotation/TOCTOU/error evidence, and -02/-03/-06 handoffs; (2) convert `docs/federation/SETUP.md` to a non-operative N-1 reference with only the required external-bootstrap → TLS/roles → runner `--run` → `--verify` → Gateway-readiness sequence; (3) broaden scanner grammar plus path-specific semantic negatives so indirect first-boot/startup/init/Compose authority cannot be masked by a named, normative, or status record; (4) synchronize PRD/shared/tasks/index/sitemap/federation task state and this scratchpad; (5) run formatting, links, strict contracts, complete-doc scanner/semantic assertions, diff/operator inventory, material/manifest overlap, review, docs-only stage, commit, queue guard, push, and exact remote-SHA verification.
|
||||
- **TDD decision:** skipped because this bounded change is documentation-only; the affected -02/-03/-05/-06 implementation tests and scanner semantic fixtures are specified as mandatory future evidence.
|
||||
- **Review correction:** independent Codex review found the initial `10003` producer → immutable `10002` importer artifact handoff impossible. rc.12 now specifies the required privileged deployment handoff controller: after runner success it safe-opens/verifies producer artifact plus generation, exact-byte copies/fsyncs/atomically renames to a distinct `10002:10002` `0400` importer mount, seals it read-only, and starts no importer on partial/wrong-generation/owner/mode failure. It receives only a root-owned non-secret expected-version/URL-digest/generation descriptor plus public verifier key, never URL bytes/private key; producer/importer share no writable file or mount. Dry-run nonce consumption now requires fresh `--verify` and artifact before `--yes`; the active-route schema requires `targetCredentialVersionFile`. Pre-existing `.mosaic` state is confirmed excluded from staging.
|
||||
- **Validation result:** changed-doc Prettier and `git diff --check` PASS; strict `pnpm exec tsc --noEmit -p docs/native-kanban-sot/tsconfig.json` PASS; contract/SETUP material and non-operative semantic assertions PASS. Pending final docs-only stage, commit, queue guard, push, and remote-head verification.
|
||||
|
||||
## 2026-07-15 — rc.13 MILESTONES semantic-scan remediation intake
|
||||
|
||||
- **Objective / scope:** Close only HIGH-1 from `/home/hermes/agent-work/reviews/771-kbn101-contract-rereview7-7365dcf.md` against `7365dcf15c09262a46132b9c011769ad98243641`. Documentation/tracking only: assign `docs/federation/MILESTONES.md` exclusively to KBN-101-07 and its exact former startup-extension wording to the KBN-101-06 semantic fixture/inventory; replace the wording with a non-operative historical/status disposition. No source, deployment, Compose, CI, Vault, migration, provider, or `.mosaic` artifact is authorized.
|
||||
- **Frozen remediation:** runtime/startup extension provisioning is superseded and forbidden. The sole eligible sequence is external bootstrap → TLS/roles → `mosaic-db-migrator --run` → `mosaic-db-migrator --verify` → Gateway readiness. The MILESTONES record authorizes no current DDL, Compose/init, or startup path. The scanner must prove the exact former wording fails before any inventory/status-only mask and rerun its full current-doc operator/deploy-manifest scan outside reports/scratchpads.
|
||||
- **Plan:** update only MILESTONES plus the exact KBN-101 contract/inventory/manifests and necessary PRD/shared/task/index/sitemap/version/status references; run full lexical+semantic scan, Prettier, links, strict contract TypeScript, diff and manifest-overlap checks; stage docs only (excluding pre-existing `.mosaic`), commit, queue-guard, push, and verify the exact remote SHA. No source-code TDD applies because this bounded task changes documentation only.
|
||||
- **Remediation result (pre-commit):** `MILESTONES.md` now makes runtime/startup extension provisioning superseded and forbidden, with only external bootstrap → TLS/roles → `mosaic-db-migrator --run` → `--verify` → Gateway readiness; it authorizes no current DDL/Compose/init/startup path. The KBN-101-07 manifest/inventory is exclusive and KBN-101-06 documents the exact former wording as a semantic negative that fails before inventory masking. Full current-doc scan outside reports/scratchpads: 103 Markdown files, 10 lexical-hit paths classified, 2 owned Compose-before-runner references, and zero ownerless indirect/literal routes. Prettier, strict native-kanban TypeScript, local links (64/0), diff check, and 95-path manifest-overlap reconstruction (0 overlaps; MILESTONES only -07) passed. Pre-existing `.mosaic/orchestrator/{mission.json,session.lock}` remains excluded.
|
||||
- **Delivery checkpoint:** committed remediation as `237bac81c93dc4305470cea23a67e4ced730bd61` (`docs(#771): close MILESTONES startup authority`). Push queue guard returned `state=unknown` without failure; the push hook ran repository `pnpm typecheck`, `pnpm lint`, and `pnpm format:check`, all PASS; the remote branch resolved to that exact SHA. This final evidence append is committed next, then the exact remote head is verified and the branch waits for independent exact-head rereview/Ultron reverify. `.mosaic` remains unstaged.
|
||||
|
||||
## 2026-07-15 — rc.13 current-document safety remediation intake
|
||||
|
||||
- **Objective / scope:** Close only HIGH-1 and HIGH-2 in `/home/hermes/agent-work/reviews/771-kbn101-contract-rereview8-aeacc70.md` against exact head `aeacc702353740aad0f2f086974cc0670e360d1d`. This is documentation/tracking only. Preserve all prior gates; do not edit source, Compose, deployment artifacts, CI, migrations, Vault data, reports, or `.mosaic`.
|
||||
- **Source-backed decision:** Current `docker-compose.yml` mounts `infra/pg-init` into PostgreSQL init and that SQL creates `vector`; it cannot be used as a current PostgreSQL start route before KBN-101 bootstrap/runner artifacts exist. The checked-in configuration declares a supported `local` PGlite tier (`DEFAULT_LOCAL_CONFIG` and `tier-detection` both establish in-process PGlite with no external service probe), so docs may retain a local/PGlite route and start only non-PostgreSQL Compose services such as `valkey`.
|
||||
- **Plan:** (1) replace the README and dev-guide Compose-first PostgreSQL instructions with a PGlite/no-PostgreSQL developer path and an explicit held PostgreSQL/federated future activation sequence; (2) replace the deployment quick-start and bare-metal production procedure with non-operative status, no production `.env`/automatic dotenv/`EnvironmentFile`/credential export-or-argv/restart guidance, and only a non-executable future renderer/Vault generation-pinned process-exec or `LoadCredential` schematic; (3) expand KBN-101-06/-07 semantic fixture/disposition language to fail the exact former README/dev/deployment Compose-first sequences and production credential routes before ownership/status masking; (4) synchronize PRD/shared/tasks/index/sitemap/status/version and this scratchpad; (5) run formatting, links, strict contract TypeScript, full current-doc lexical+semantic scan outside reports/scratchpads, manifest-overlap, review, docs-only stage, commit, queue guard, push, and exact remote-SHA verification.
|
||||
- **TDD decision:** no source or fixture implementation may be changed in this documentation-only remediation. The -06 future fixture requirements are frozen as acceptance evidence; validation here is static semantic inventory plus documentation quality gates.
|
||||
- **Correction from independent review:** The initial local-Gateway PGlite wording was unsafe. `apps/gateway/src/main.ts` loads daemon/root/app-local environment files before tier selection; an inherited daemon `DATABASE_URL` can select PostgreSQL, whose current startup reaches extension creation and migrations. No source is authorized in this docs-only task. The remediation therefore holds Gateway/Web local startup, preserves only PGlite data-layer plus selected non-PostgreSQL Compose work, and assigns KBN-101-02 the fail-closed daemon/inherited/root/app-local DSN and non-local-tier rejection before connection/DDL, with a regression proof. The future renderer boundary remains KBN-101-05.
|
||||
- **Remediation evidence:** Removed active PostgreSQL Compose-first and production credential guidance from README, CLAUDE, dev/deployment, and the residual historical TUI/MCP routes; local documentation now permits only PGlite data-layer/non-PostgreSQL Valkey work while Gateway/Web startup is explicitly held. KBN-101-06/-07 now freeze exact former README/dev/deployment Compose sequences plus production credential patterns as pre-classification semantic negatives. Independent review surfaced the current daemon/root/app dotenv loader as an unsafe source boundary; no source is authorized here, so the docs hold that startup and assign fail-closed removal/regression proof to KBN-101-02.
|
||||
- **Validation:** Prettier PASS (12 changed docs); local-link resolver PASS (71 links); `pnpm exec tsc --noEmit -p docs/native-kanban-sot/tsconfig.json` PASS; full README/CLAUDE/docs inventory PASS (104 documents, 0 active non-normative Compose/init/production-credential violations); manifest reconstruction PASS (10 cards, 95 declared path tokens, 0 overlaps); `git diff --check` PASS. Pre-existing `.mosaic/orchestrator/{mission.json,session.lock}` remains intentionally unstaged.
|
||||
- **Final route correction:** Held the residual MCP environment/restart and historical TUI smoke-test routes after review showed they could bypass the Gateway local-start hold; no bearer-token-over-HTTP or Gateway restart route remains in this remediation scope.
|
||||
- **Delivery:** committed `7cc156b777189ee89448e4d569a8b3f69560a240` (`docs(#771): hold unsafe database startup routes`) and `d8f935c20ade835aa3ec03fe5d6961885d8b5f0b` (`docs(#771): record final route correction`). Push queue guard returned `state=unknown` without failure; both pushes completed and the remote matched `d8f935c` before this final delivery-evidence append. `.mosaic/orchestrator/{mission.json,session.lock}` remains pre-existing and unstaged. Await a fresh independent exact-head re-review/Ultron verification.
|
||||
|
||||
## 2026-07-15 — rc.15 exact one-finding runner/legacy-CI remediation intake
|
||||
|
||||
- **Objective / scope:** Close only HIGH-1 in `/home/hermes/agent-work/reviews/771-kbn101-contract-rereview9-be0ebfd.md` against `be0ebfdc6a2b32a0ab6989117ebbb12f43854d71`. Documentation/tracking only: no source, Compose, CI, deployment, migration, Vault, reports, or `.mosaic` edit.
|
||||
- **Plan:** Replace imperative current runner routes in the architecture plan and PERFORMANCE with one explicit non-operative future procedure; make fleet backlog current behavior PGlite-only and PostgreSQL held; classify README's checked-in direct CI `db:migrate` as legacy N-1/uncertified/non-authorizing pending KBN-101-06 removal; then extend the future KBN-101-06 lexical/semantic inventory contract so unqualified runner/current-CI authority fails before masking while only the complete named held procedure passes. Synchronize required contract/PRD/shared/task/index/sitemap state, run full docs scan and document gates, stage docs only, commit, queue-guard, push, and verify remote SHA.
|
||||
- **TDD decision:** not applicable: the user authorizes documentation only and the named -06 fixture/inventory files do not yet exist; the contract records their required future implementation evidence.
|
||||
- **Remediation / review closure:** Architecture, PERFORMANCE, federation SETUP/MILESTONES, deployment/dev/migrate-tier, and README now use one Markdown-bounded `Held future procedure` form where needed: non-operative/no-current-command-authority; KBN-101-00/-03/-05; external bootstrap → TLS/roles → `mosaic-db-migrator --run` → `mosaic-db-migrator --verify` → Gateway/Compose readiness. Any runner hit outside that section is a -06 semantic failure. Fleet backlog current behavior is PGlite-only. README accurately records the checked-in direct CI migration as an active, isolated-disposable-database, uncertified legacy N-1 DDL exception that is non-authorizing as an operator route and pending -06 removal; it no longer falsely claims the current CI role lacks DDL capability. Independent Codex review found and this pass closed the readiness-endpoint, standalone-verify, CI-factuality, and scanner-boundary findings. Its only residual finding concerns pre-existing tracked `.mosaic/orchestrator` runtime state, which is explicitly excluded and unstaged by task scope; security review found no vulnerability.
|
||||
- **Validation:** changed-doc Prettier PASS; `pnpm exec tsc --noEmit -p docs/native-kanban-sot/tsconfig.json` PASS; changed-doc local links 72/0; `git diff --check` PASS; full README/CLAUDE/docs lexical+semantic scan outside reports/scratchpads PASS (106 Markdown documents; 7 operator documents with runner tokens; zero unqualified future-runner/current-CI authority routes); KBN-101 manifest check PASS (10 dependency-ordered cards; -07 docs/-06 fixtures disjoint); docs-only allowlist PASS (16 docs, pre-existing `.mosaic` excluded).
|
||||
- **Delivery evidence:** committed `d857463a8a4658e34a77177737860cf82cc26ac6` (`docs(#771): hold unimplemented runner routes`). Pre-push queue guard returned `state=unknown` without failure; the push hook ran repository `pnpm typecheck`, `pnpm lint`, and `pnpm format:check`, all PASS. Push succeeded and `origin/docs/771-kbn101-db-role-split` matched `d857463a8a4658e34a77177737860cf82cc26ac6`. This evidence append is committed and pushed next; `.mosaic/orchestrator/{mission.json,session.lock}` stays pre-existing, unstaged, and excluded. Await exact-head independent re-review/Ultron reverify.
|
||||
|
||||
## 2026-07-15 — rc.16 exact one-finding generic-wrapper remediation intake
|
||||
|
||||
- **Objective / scope:** Close only HIGH-1 in `/home/hermes/agent-work/reviews/771-kbn101-contract-rereview10-18e253c.md` against exact head `18e253c8790bdbb5bc30a06c116472213b83b22f`. Documentation/tracking only: no source, Compose, CI, deployment, migration, Vault, report, or `.mosaic` change.
|
||||
- **Source-backed correction:** `packages/storage/src/cli.ts` currently labels `storage migrate` a thin wrapper for `pnpm --filter @mosaicstack/db db:migrate` and executes that direct Drizzle command with `execSync`; no `mosaic-db-migrator` executable exists. Therefore the README commented form and user-guide executable form must not describe runner delegation or provide current command authority.
|
||||
- **Plan:** Remove the current wrapper command from README/user-guide command guidance; record it as legacy N-1, uncertified, non-operative, and forbidden pending KBN-101-02/-03/-06/-08 activation. Retain only the held future ordered bootstrap → TLS/roles → runner `--run` → `--verify` → readiness sequence and the separately held secure migrate-tier route. Extend KBN-101-06's future semantic fixture/matrix with both exact former forms (including the README commented code-fence form), requiring their failure before inventory/status masking and a source-consistency assertion that direct Drizzle wrapper source cannot be described as runner delegation. Synchronize status/version references only where required, then run document gates, stage docs only, commit, queue-guard, push, and verify the remote SHA.
|
||||
- **TDD decision:** not applicable: this bounded task changes documentation only; the required future -06 semantic/source-consistency fixtures are specified as implementation acceptance evidence.
|
||||
- **Remediation / validation:** README and user-guide remove the generic wrapper from command guidance and state the direct-Drizzle current-source truth, legacy-N-1/uncertified/non-operative MUST-NOT-INVOKE boundary, named -02/-03/-06/-08 activation cards, future external-bootstrap → TLS/roles → runner `--run` → `--verify` → readiness sequence, and separately held secure migrate-tier route. The KBN-101 rc.16 contract records both exact former forms (README commented code fence and user-guide executable code fence), requires failure before inventory/ownership/status masking, and requires the direct-Drizzle/no-runner-bin source-consistency proof. Prettier passed on all nine changed Markdown documents; changed-doc local links passed (72/0); strict native-kanban contract TypeScript and `git diff --check` passed; full README/CLAUDE/docs scan outside reports/scratchpads passed (106 documents, zero non-normative executable generic-wrapper or false runner-delegation route); and manifest validation passed (10 cards, 90 exact tokens, zero overlaps). Pre-existing `.mosaic/orchestrator/{mission.json,session.lock}` remains excluded.
|
||||
@@ -1,110 +0,0 @@
|
||||
# FCM-M2-001 — Generated Environment Boundary
|
||||
|
||||
- **Issue/card:** #758 / FCM-M2-001
|
||||
- **Branch/base:** `feat/758-generated-env-boundary` from `origin/main` `e9c4aa3e8b3780719cd5a43c0ef3f37fc70de666`
|
||||
- **Budget assumption:** 30K-card budget; implement only the deterministic generated/local environment boundary and its launch-chain/docs/tests.
|
||||
|
||||
## Objective
|
||||
|
||||
Replace the generic fleet agent `.env` authority/merge path with a deterministic roster-derived `<agent>.env.generated` projection and strict, data-only `<agent>.env.local`. The roster remains the desired-state authority. Reject bad input before the launcher creates a tmux session; never print sensitive or privileged-command values.
|
||||
|
||||
## Scope and non-goals
|
||||
|
||||
- In scope: deterministic render/write, strict generated/local parse rules, legacy `.env` disposition/quarantine, systemd/launcher boundary, permission/path checks, focused fail-closed tests, operator/reference documentation, USC interface evidence.
|
||||
- Excluded: roster CRUD/mutation, v2 roster schema changes, lifecycle/reconcile/apply behavior, migration/canary rollout, connectors, remote surfaces, live-fleet actions, and M2-002.
|
||||
|
||||
## Plan
|
||||
|
||||
1. Add red tests for generated-key shadowing, malformed/duplicate/unknown/command/sensitive input, no-value diagnostics, deterministic/idempotent projection, secure file modes, and legacy disposition.
|
||||
2. Implement a pure strict environment contract plus atomic projection/quarantine helper.
|
||||
3. Replace the generic `.env` writer/merge path and systemd reference with `.env.generated` + `.env.local` ownership.
|
||||
4. Make the shell launcher parse the files without `source`/`eval`, reject unsafe input before tmux creation, and construct only the roster-derived runtime command.
|
||||
5. Add operator/reference documentation with the requested USC M1 interface evidence and M2–M4 gate statement.
|
||||
6. Run focused/package/root gates and audit the USC interface packet. Per continuation scope, stop before review, commit, push, PR, or live mutation.
|
||||
|
||||
## Initial evidence
|
||||
|
||||
- No existing owner: target worktree path absent; no target local/remote branch; `pr-list.sh -s open` returned no open PRs.
|
||||
- M1 compiler/API/docs and executable disposition evidence are present at the assigned base.
|
||||
- Existing launch chain writes `fleet/agents/<agent>.env`, preserves arbitrary legacy lines via `mergeAgentEnv`, sources `MOSAIC_AGENT_COMMAND`, and executes it through `bash -c`; all are M2 remediation targets.
|
||||
- `~/.config/mosaic/guides/SECURITY.md` is absent. Read the available security-review role contract and the vault/secrets guide instead.
|
||||
|
||||
## Verification log
|
||||
|
||||
### Continuation (2026-07-14)
|
||||
|
||||
- Preserved the inherited 14-file delta; no reset, stash, rebase, roster mutation, lifecycle action,
|
||||
live-fleet action, commit, push, or PR action was performed.
|
||||
- Focused gates passed:
|
||||
- `pnpm --dir packages/mosaic test -- src/fleet/generated-env-boundary.spec.ts` — 1 file, 10 tests passed.
|
||||
- `bash packages/mosaic/framework/tools/fleet/test-start-agent-session.sh` — passed.
|
||||
- `bash packages/mosaic/framework/systemd/user/test-fleet-units.sh` — passed.
|
||||
- `pnpm --dir packages/mosaic test -- src/commands/fleet.spec.ts` — 1 file, 192 tests passed.
|
||||
- Package gates passed before final documentation/format follow-up:
|
||||
- `pnpm --dir packages/mosaic typecheck` — passed.
|
||||
- `pnpm --dir packages/mosaic lint` — passed.
|
||||
- `pnpm --dir packages/mosaic test` — 52 files, 752 tests passed.
|
||||
- `pnpm format:check` initially failed only for the new boundary reference and generated-boundary
|
||||
TypeScript files; targeted Prettier normalization was applied. A final `pnpm format:check` passed.
|
||||
- USC packet audit: the M1 structural compiler is `parseRosterV2` with roster `version: 2`; the
|
||||
semantic resolver is `validateRosterV2Semantics`; disposition artifacts retain `version: 1` fixture
|
||||
evidence. `docs/TASKS.md` records M1-001 done, M1-002 in-progress, and M1-003 not-started; no
|
||||
product release version is claimed. The packet now distinguishes these statuses from checkout
|
||||
artifact presence and states the M2 → M3 → M4 downstream gates.
|
||||
|
||||
## Review remediation (2026-07-14)
|
||||
|
||||
- **Blocker 1 red-first:** Added a launcher reproducer with a `0777` `fleet/agents` parent and a private generated file. Before implementation, `bash packages/mosaic/framework/tools/fleet/test-start-agent-session.sh` failed: `FAIL: generated file under a world-writable parent was accepted`. The failure occurred after the launch path reached fake tmux, proving the parent was not validated.
|
||||
- **Blocker 2 red-first:** Added a `symlink()` projection-directory reproducer that preloads generated/local/quarantine/legacy target files and asserts no target mutation. Before implementation, `pnpm --dir packages/mosaic test -- src/fleet/generated-env-boundary.spec.ts` failed the new test because the existing writer followed the `agentEnvDir` symlink and parsed its target legacy input (`expected /unsafe-directory/i`, received `code=malformed-line`). The initial test-only missing `mkdir` import was corrected before recording this behavior failure.
|
||||
- **Blocker 3 red-first:** Added fresh/stale/absent native-heartbeat regression coverage. Before implementation, an isolated fake-tmux launcher reproducer with a fresh `<agent>.hb.native` marker failed `FAIL: fresh native heartbeat was overwritten`; the existing sidecar immediately replaced native `status=busy`/`model` content.
|
||||
- **Remediation result:** The launcher now rejects a group/world-accessible or symlinked `fleet/agents` parent before an environment read or tmux call. The projection writer uses `lstat` before chmod/write processing and rejects a symlinked directory without creating generated/local/quarantine files or deleting legacy input. The heartbeat sidecar defers to a fresh non-symlink native marker and falls back when stale/absent. Focused green evidence before independent review: `bash packages/mosaic/framework/tools/fleet/test-start-agent-session.sh` and `pnpm --dir packages/mosaic test -- src/fleet/generated-env-boundary.spec.ts` (11 tests) passed.
|
||||
- **Independent-review follow-up red-first:** Codex code review returned one blocker and security review one medium CWE-732 finding: the writer repaired an already `0777` directory with `chmod` before trusting its contents. Added a reproducer with a safe local file beneath an existing `0777` directory. Before the follow-up fix, `pnpm --dir packages/mosaic test -- src/fleet/generated-env-boundary.spec.ts` failed because the promise resolved and wrote `coder0.env.generated` instead of rejecting.
|
||||
- **Independent-review remediation:** Existing directories now pass non-following private-directory validation before any read or chmod; only a directory created in this call is normalized to `0700`. The fleet-add test fixture now creates its simulated trusted `fleet/agents` boundary at `0700`; this corrects fixture setup to match the new required contract rather than weakening the rejection assertion. Focused reruns passed: generated-boundary 12 tests, launcher boundary suite, and fleet suite 192 tests.
|
||||
- **Final verification before re-review:** Launcher + systemd suites passed; package suite passed (52 files, 754 tests); package lint/typecheck, root typecheck (42 tasks), format check, and diff check passed. The rerun code review still reports a tmux command-arity blocker, and the security rerun reports systemd `EnvironmentFile` pre-validation injection findings for both agent units. These were discovered after the specified three-remediation scope; no additional source changes were made. Independent review therefore remains `REQUEST CHANGES` despite the requested three fixes passing their behavioral suites.
|
||||
|
||||
## Systemd pre-validation remediation (2026-07-14)
|
||||
|
||||
- **Red-first:** Updated the fleet unit contract to reject any `EnvironmentFile=` projection preload, require a cleared bootstrap environment, and require a validated exact-stop path. Before implementation, `bash packages/mosaic/framework/systemd/user/test-fleet-units.sh` failed: `FAIL: agent units must not preload projections before strict parsing`.
|
||||
- **Red-first parser/stop coverage:** Added interaction-wrapper and exact-stop cases to the launcher boundary suite. Before implementation, `bash packages/mosaic/framework/tools/fleet/test-start-agent-session.sh` failed: `FAIL: interaction did not use shared strict parser first`, because the interaction wrapper consumed inherited environment before projection validation.
|
||||
- **Focused green:** Both unit templates now use `env -i` with fixed `HOME`, agent instance, and PATH; neither has `Environment=`/`EnvironmentFile=`. The interaction wrapper delegates to `start-agent-session.sh --interaction`, so strict generated/local parsing precedes pinned Pi profile checks. `--stop` reuses the strict generated parser before exact `=<agent>` socket/session termination. Passed: systemd unit suite, launcher boundary suite (including malformed interaction, pinned profile, and ambient-socket stop cases), and 210 focused TypeScript tests.
|
||||
- **Final verification:** `pnpm --dir packages/mosaic test` passed (52 files, 754 tests); package lint/typecheck, root typecheck (42 tasks), format/diff, and shell syntax checks passed. Security review passed with no findings. Code review repeated the previously refuted tmux argv concern and a pre-existing Claude trust-lock suggestion; per the assigned narrow follow-up, no tmux or unrelated trust-path change was made.
|
||||
|
||||
## Risks and next review
|
||||
|
||||
- This card is uncommitted and unreleased. The canonical tracker still records its dependencies as
|
||||
M1-002 in progress and M1-003 not started; this continuation does not reinterpret those task states.
|
||||
- Final post-documentation checks passed: `pnpm --dir packages/mosaic typecheck`,
|
||||
`pnpm --dir packages/mosaic lint`, `pnpm --dir packages/mosaic test` (52 files, 752 tests),
|
||||
`pnpm typecheck` (42 Turbo tasks), and `pnpm format:check`.
|
||||
- Obtain independent code and security review of the complete delta next. Do not run commit, push,
|
||||
PR, or live-fleet commands in this continuation.
|
||||
|
||||
## Fresh-install directory remediation (2026-07-14)
|
||||
|
||||
- **Objective:** Remediate only the fresh-install path where `installFleet` created `fleet/agents`
|
||||
with host-umask permissions before the boundary writer correctly rejected it.
|
||||
- **Plan:** Add a real `fleet install --no-enable` integration reproducer; prove red; let the
|
||||
existing boundary writer own directory creation; run focused and full gates. No commit, push,
|
||||
PR, review disposition, or live-fleet action.
|
||||
- **Red evidence:** Before the one-line remediation,
|
||||
`pnpm --dir packages/mosaic test -- src/commands/fleet.spec.ts` failed the new test with
|
||||
`AgentEnvBoundaryError: code=unsafe-permissions` at `ensurePrivateProjectionDirectory`, after
|
||||
`installFleet` pre-created the directory.
|
||||
- **Change:** Removed only the recursive `mkdir(activePaths.agentEnvDir)` in `installFleet`.
|
||||
`writeAgentEnvironmentProjection` remains the sole creator and retains its existing `lstat`,
|
||||
private-directory, symlink, and existing-unsafe-directory fail-closed checks.
|
||||
- **Focused green:** `pnpm --dir packages/mosaic test -- src/commands/fleet.spec.ts` — 193 tests
|
||||
passed. The new integration executes a fresh `fleet install --no-enable`, asserts a real
|
||||
non-symlink `0700` directory and a `0600` generated projection. Existing unsafe-directory
|
||||
coverage remains in `generated-env-boundary.spec.ts` and asserts no chmod repair/no generated
|
||||
file write.
|
||||
- **Full gates green:** generated-boundary 12 tests; launcher and systemd suites; package
|
||||
typecheck/lint and 52 files / 755 tests; root typecheck (42 tasks), lint, format, diff check,
|
||||
and root test (42 tasks) all passed.
|
||||
- **Independent review:** The complete inherited uncommitted delta still has Codex `REQUEST CHANGES`
|
||||
findings outside this narrow fix (tmux command arity and Claude trust-lock regression), plus a
|
||||
security-review medium finding on unvalidated writable ancestor directories. No out-of-scope
|
||||
source changes were made.
|
||||
- **Risk:** The writer's existing create-then-validate sequence is relied on for the creation
|
||||
boundary; a concurrent substitution causes fail-closed validation rather than repair. The
|
||||
review findings above remain residual risks for the complete card delta.
|
||||
@@ -43,4 +43,3 @@
|
||||
| TESS-M5-002 | done | Complete migration inventory, cutover, rollback, retention and deprecation evidence | #711 | coder3 | docs/tess | feat/tess-migration-docs | TESS-M4-V | 18K | TESS-MIG-001. **Mos-DISPATCHED to coder3 2026-07-13** ("M4 complete; advancing to M5") — dispatched AHEAD of TESS-M4-V passing; the M4-V-status-vs-#710-CLOSED dependency reconciliation is pending Mos ruling (tracked, not orchestrator-decided). **DELIVERED as PR #742** — 4 new files docs/tess/M5-MIGRATION-{INVENTORY,CUTOVER,ROLLBACK,RETENTION-DEPRECATION}.md, base main b7b0f508, frozen head b5e9d0e528a50aae2e916cc7202bacc2e8db67dd. Docs-only; tracking-control trio (MISSION-MANIFEST/TASKS/VERIFICATION-MATRIX) UNTOUCHED; command-authz byte-identical a9f829e7; no live creds. **CI pipeline 1773 SUCCESS** (repo 47, refs/pull/742/head, commit==head). **Independent non-author ROR COMPLETE: reviewer VERIFIED APPROVE at exact head b5e9d0e528a50aae2e916cc7202bacc2e8db67dd (Gitea comment 17108)** — evidence claims verified to trace to landed Hermes adapter / capability matrix, gateway registry/reachability, operator-memory scope path, Mos coordination boundary; docs do NOT over-claim transcript/profile import, schema migration, unsupported-capability enablement, production cutover, or deprecation completion. Head independently verified UNMOVED at b5e9d0e528a5 post-ROR (live Gitea), base main, mergeable=true. **#742 MERGED by Mos → main 5789711e. Deliverable docs LANDED. GATE: TESS-M4-V/M5-V verification-gate reconciliation remains Mos-owned/pending (this row was dispatched ahead of M4-V passing).** |
|
||||
| TESS-M5-003 | done | Complete OpenAPI, user/admin/developer/plugin/operations docs and checklist | #711 | codex | docs | feat/tess-docs | TESS-M5-001,TESS-M5-002 | 22K | Documentation hard gate. **DELIVERED as PR #746** (branch feat/tess-docs, base main, 7 docs-only files: docs/openapi-tess.yaml + docs/tess/{ADMIN,DEVELOPER,OPERATIONS,PLUGIN,USER}-GUIDE.md + M5-003-DOCUMENTATION-CHECKLIST.md). 4-round revise-loop (heads 470eb911→c9f69300→7aea94e2→25b9d642) converged: OpenAPI covers interaction routes + SSE /sessions/{sessionId}/stream + Mos coord (/api/coord/mos/handoff,/observe,/result) + memory preferences/insights/search; request-body schemas aligned to real DTOs (Send requires content+idempotencyKey, Stop requires approvalRef, Insight requires only content, MosHandoff body requires idempotencyKey+summary); checklist accurate. **CI pipeline 1786 SUCCESS** (repo 47, refs/pull/746/head, commit==head 25b9d642). **Independent non-author ROR COMPLETE: reviewer VERIFIED APPROVE at exact head 25b9d642b939014b3efd61826e7524cafc6ffc2e (Gitea comment 17170)** — docs-only, tracking-trio untouched, command-authz byte-identical a9f829e7, no false coverage claims. Head verified UNMOVED at 25b9d642 (live Gitea, not worker-reported), base main, mergeable=true. **#746 MERGED by Mos → main bc8016c8314ec3a4b6ebc2fec5d9f276fca3327a. Documentation gate LANDED.** GATE: TESS-M4-V/M5-V verification-gate reconciliation remains Mos-owned/pending. |
|
||||
| TESS-M5-V | not-started | Full baseline, contract, integration, Discord/CLI E2E, security review, recovery drill and rollback qualification | #711 | sonnet | apps/gateway, packages/agent, plugins/discord, packages/mosaic | review/tess-final | TESS-M5-003 | 35K | Maps AC-TESS-01..11 to evidence |
|
||||
| MOS-PORT-M1-001 | in-progress | Implement logical Mos identity, PostgreSQL connector lease, monotonic fencing, server-bound execution grants, audit, migrations, concurrency/restart/abuse/integration tests | #755 | codex | packages/types, packages/agent, packages/db, apps/gateway | feat/mos-logical-identity-fencing | — | 38K | Requirements MOS-PORT-ID-001, MOS-PORT-LEASE-001, MOS-PORT-FENCE-001..002, MOS-PORT-OBS-001, MOS-PORT-ARCH-001. One Sol/Pi worker; TDD; PR-open STOP; worker must not edit this ledger. |
|
||||
|
||||
@@ -1,261 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type {
|
||||
ConnectorExecutionContext,
|
||||
ConnectorExecutionGrant,
|
||||
ConnectorLease,
|
||||
ConnectorLeaseAuditEvent,
|
||||
ConnectorLeaseStore,
|
||||
FencedConnectorAdapter,
|
||||
LogicalAgentBinding,
|
||||
} from '@mosaicstack/types';
|
||||
import {
|
||||
ConnectorLeaseCoordinator,
|
||||
MAX_CONNECTOR_GRANT_TTL_MS,
|
||||
MAX_CONNECTOR_LEASE_TTL_MS,
|
||||
} from './connector-lease.js';
|
||||
import type { ConnectorLeaseError } from './connector-lease.js';
|
||||
|
||||
const identity = { tenantId: 'tenant-a', logicalAgentId: 'mos' } as const;
|
||||
const binding: LogicalAgentBinding = { identity, bindingId: 'operator-chat' };
|
||||
const activeLease: ConnectorLease = {
|
||||
...binding,
|
||||
leaseId: '00000000-0000-4000-8000-000000000001',
|
||||
connectorId: 'connector-a',
|
||||
scopes: ['runtime.send', 'tool.execute'],
|
||||
leaseEpoch: '3',
|
||||
acquiredAt: '2026-07-14T17:00:00.000Z',
|
||||
heartbeatAt: '2026-07-14T17:00:00.000Z',
|
||||
expiresAt: '2026-07-14T17:10:00.000Z',
|
||||
};
|
||||
|
||||
class FakeLeaseStore implements ConnectorLeaseStore {
|
||||
lease: ConnectorLease | null = activeLease;
|
||||
readonly audits: ConnectorLeaseAuditEvent[] = [];
|
||||
|
||||
async acquire(): Promise<ConnectorLease> {
|
||||
if (!this.lease) throw new Error('fixture has no lease');
|
||||
return this.lease;
|
||||
}
|
||||
|
||||
async takeover(): Promise<ConnectorLease> {
|
||||
if (!this.lease) throw new Error('fixture has no lease');
|
||||
return this.lease;
|
||||
}
|
||||
|
||||
async heartbeat(): Promise<ConnectorLease> {
|
||||
if (!this.lease) throw new Error('fixture has no lease');
|
||||
return this.lease;
|
||||
}
|
||||
|
||||
async release(): Promise<void> {}
|
||||
|
||||
async findCurrent(): Promise<ConnectorLease | null> {
|
||||
return this.lease;
|
||||
}
|
||||
|
||||
async recordAudit(event: ConnectorLeaseAuditEvent): Promise<void> {
|
||||
this.audits.push(event);
|
||||
}
|
||||
}
|
||||
|
||||
describe('ConnectorLeaseCoordinator fencing', (): void => {
|
||||
it('validates a server-minted grant immediately before invoking an adapter side effect', async (): Promise<void> => {
|
||||
const store = new FakeLeaseStore();
|
||||
const coordinator = new ConnectorLeaseCoordinator(store, {
|
||||
now: (): Date => new Date('2026-07-14T17:01:00.000Z'),
|
||||
});
|
||||
const grant = await coordinator.issueGrant({
|
||||
lease: activeLease,
|
||||
scopes: ['runtime.send'],
|
||||
ttlMs: 30_000,
|
||||
correlationId: 'correlation-1',
|
||||
});
|
||||
const execute = vi.fn(async (_input: string, context: ConnectorExecutionContext) => context);
|
||||
const adapter: FencedConnectorAdapter<string, ConnectorExecutionContext> = { execute };
|
||||
|
||||
const context = await coordinator.executeGrant(grant, 'runtime.send', 'hello', adapter);
|
||||
|
||||
expect(execute).toHaveBeenCalledOnce();
|
||||
expect(context).toMatchObject({
|
||||
identity,
|
||||
bindingId: 'operator-chat',
|
||||
connectorId: 'connector-a',
|
||||
leaseEpoch: '3',
|
||||
scopes: ['runtime.send'],
|
||||
});
|
||||
});
|
||||
|
||||
it('caps grant expiry to the durable current lease instead of submitted metadata', async (): Promise<void> => {
|
||||
const store = new FakeLeaseStore();
|
||||
store.lease = { ...activeLease, expiresAt: '2026-07-14T17:01:05.000Z' };
|
||||
const coordinator = new ConnectorLeaseCoordinator(store, {
|
||||
now: (): Date => new Date('2026-07-14T17:01:00.000Z'),
|
||||
});
|
||||
|
||||
const grant = await coordinator.issueGrant({
|
||||
lease: { ...activeLease, expiresAt: '2026-07-14T18:00:00.000Z' },
|
||||
scopes: ['runtime.send'],
|
||||
ttlMs: 30_000,
|
||||
correlationId: 'correlation-durable-expiry',
|
||||
});
|
||||
|
||||
expect(grant.expiresAt).toBe('2026-07-14T17:01:05.000Z');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['forged clone', (grant: ConnectorExecutionGrant): ConnectorExecutionGrant => ({ ...grant })],
|
||||
[
|
||||
'cross-tenant clone',
|
||||
(grant: ConnectorExecutionGrant): ConnectorExecutionGrant => ({
|
||||
...grant,
|
||||
identity: { ...grant.identity, tenantId: 'tenant-b' },
|
||||
}),
|
||||
],
|
||||
[
|
||||
'cross-agent clone',
|
||||
(grant: ConnectorExecutionGrant): ConnectorExecutionGrant => ({
|
||||
...grant,
|
||||
identity: { ...grant.identity, logicalAgentId: 'other-agent' },
|
||||
}),
|
||||
],
|
||||
[
|
||||
'cross-binding clone',
|
||||
(grant: ConnectorExecutionGrant): ConnectorExecutionGrant => ({
|
||||
...grant,
|
||||
bindingId: 'other-binding',
|
||||
}),
|
||||
],
|
||||
[
|
||||
'cross-connector clone',
|
||||
(grant: ConnectorExecutionGrant): ConnectorExecutionGrant => ({
|
||||
...grant,
|
||||
connectorId: 'connector-b',
|
||||
}),
|
||||
],
|
||||
])('denies and audits a %s before adapter invocation', async (_label, forge): Promise<void> => {
|
||||
const store = new FakeLeaseStore();
|
||||
const coordinator = new ConnectorLeaseCoordinator(store, {
|
||||
now: (): Date => new Date('2026-07-14T17:01:00.000Z'),
|
||||
});
|
||||
const grant = await coordinator.issueGrant({
|
||||
lease: activeLease,
|
||||
scopes: ['runtime.send'],
|
||||
ttlMs: 30_000,
|
||||
correlationId: 'correlation-forged',
|
||||
});
|
||||
const adapter = { execute: vi.fn().mockResolvedValue(undefined) };
|
||||
|
||||
await expect(
|
||||
coordinator.executeGrant(forge(grant), 'runtime.send', undefined, adapter),
|
||||
).rejects.toMatchObject({ code: 'forged_grant' } satisfies Partial<ConnectorLeaseError>);
|
||||
expect(adapter.execute).not.toHaveBeenCalled();
|
||||
expect(store.audits.at(-1)).toMatchObject({
|
||||
event: 'reject',
|
||||
outcome: 'denied',
|
||||
reason: 'forged_grant',
|
||||
correlationId: 'correlation-forged',
|
||||
});
|
||||
});
|
||||
|
||||
it('denies malformed forged grants with sanitized audit metadata', async (): Promise<void> => {
|
||||
const store = new FakeLeaseStore();
|
||||
const coordinator = new ConnectorLeaseCoordinator(store, {
|
||||
now: (): Date => new Date('2026-07-14T17:01:00.000Z'),
|
||||
});
|
||||
const adapter = { execute: vi.fn().mockResolvedValue(undefined) };
|
||||
|
||||
await expect(
|
||||
coordinator.executeGrant(
|
||||
// @ts-expect-error Deliberately exercise malformed runtime input at the trust boundary.
|
||||
{},
|
||||
'runtime.send',
|
||||
undefined,
|
||||
adapter,
|
||||
),
|
||||
).rejects.toMatchObject({ code: 'forged_grant' } satisfies Partial<ConnectorLeaseError>);
|
||||
expect(adapter.execute).not.toHaveBeenCalled();
|
||||
expect(store.audits).toContainEqual(
|
||||
expect.objectContaining({
|
||||
identity: { tenantId: 'untrusted', logicalAgentId: 'untrusted' },
|
||||
bindingId: 'untrusted',
|
||||
connectorId: 'untrusted',
|
||||
correlationId: 'untrusted',
|
||||
reason: 'forged_grant',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects lease and grant TTLs above server-side safety caps', async (): Promise<void> => {
|
||||
const store = new FakeLeaseStore();
|
||||
const coordinator = new ConnectorLeaseCoordinator(store, {
|
||||
now: (): Date => new Date('2026-07-14T17:01:00.000Z'),
|
||||
});
|
||||
expect(
|
||||
() =>
|
||||
new ConnectorLeaseCoordinator(store, {
|
||||
maxLeaseTtlMs: MAX_CONNECTOR_LEASE_TTL_MS + 1,
|
||||
}),
|
||||
).toThrow(/no greater than/);
|
||||
|
||||
await expect(
|
||||
coordinator.acquire({
|
||||
identity,
|
||||
bindingId: 'operator-chat',
|
||||
connectorId: 'connector-a',
|
||||
scopes: ['runtime.send'],
|
||||
ttlMs: MAX_CONNECTOR_LEASE_TTL_MS + 1,
|
||||
correlationId: 'correlation-ttl',
|
||||
}),
|
||||
).rejects.toThrow(/no greater than/);
|
||||
await expect(
|
||||
coordinator.issueGrant({
|
||||
lease: activeLease,
|
||||
scopes: ['runtime.send'],
|
||||
ttlMs: MAX_CONNECTOR_GRANT_TTL_MS + 1,
|
||||
correlationId: 'correlation-ttl',
|
||||
}),
|
||||
).rejects.toThrow(/no greater than/);
|
||||
});
|
||||
|
||||
it('denies stale epoch, expired grant, and unauthorized scope before side effects', async (): Promise<void> => {
|
||||
let now = new Date('2026-07-14T17:01:00.000Z');
|
||||
const store = new FakeLeaseStore();
|
||||
const coordinator = new ConnectorLeaseCoordinator(store, { now: (): Date => now });
|
||||
const stale = await coordinator.issueGrant({
|
||||
lease: activeLease,
|
||||
scopes: ['runtime.send'],
|
||||
ttlMs: 30_000,
|
||||
correlationId: 'correlation-stale',
|
||||
});
|
||||
store.lease = { ...activeLease, leaseEpoch: '4', connectorId: 'connector-b' };
|
||||
const adapter = { execute: vi.fn().mockResolvedValue(undefined) };
|
||||
|
||||
await expect(
|
||||
coordinator.executeGrant(stale, 'runtime.send', undefined, adapter),
|
||||
).rejects.toMatchObject({ code: 'stale_epoch' } satisfies Partial<ConnectorLeaseError>);
|
||||
|
||||
store.lease = activeLease;
|
||||
const expiring = await coordinator.issueGrant({
|
||||
lease: activeLease,
|
||||
scopes: ['runtime.send'],
|
||||
ttlMs: 1_000,
|
||||
correlationId: 'correlation-expired',
|
||||
});
|
||||
now = new Date('2026-07-14T17:01:02.000Z');
|
||||
await expect(
|
||||
coordinator.executeGrant(expiring, 'runtime.send', undefined, adapter),
|
||||
).rejects.toMatchObject({ code: 'grant_expired' } satisfies Partial<ConnectorLeaseError>);
|
||||
|
||||
now = new Date('2026-07-14T17:01:00.000Z');
|
||||
const scoped = await coordinator.issueGrant({
|
||||
lease: activeLease,
|
||||
scopes: ['runtime.send'],
|
||||
ttlMs: 30_000,
|
||||
correlationId: 'correlation-scope',
|
||||
});
|
||||
await expect(
|
||||
coordinator.executeGrant(scoped, 'tool.execute', undefined, adapter),
|
||||
).rejects.toMatchObject({ code: 'scope_denied' } satisfies Partial<ConnectorLeaseError>);
|
||||
expect(adapter.execute).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,381 +0,0 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import {
|
||||
normalizeConnectorId,
|
||||
normalizeConnectorScope,
|
||||
normalizeConnectorScopes,
|
||||
normalizeCorrelationId,
|
||||
normalizeLeaseEpoch,
|
||||
normalizeLogicalAgentIdentity,
|
||||
normalizeLogicalBindingId,
|
||||
type AcquireConnectorLeaseInput,
|
||||
type ConnectorExecutionContext,
|
||||
type ConnectorExecutionGrant,
|
||||
type ConnectorLease,
|
||||
type ConnectorLeaseAuditEvent,
|
||||
type ConnectorLeaseRejectReason,
|
||||
type ConnectorLeaseStore,
|
||||
type FencedConnectorAdapter,
|
||||
type HeartbeatConnectorLeaseInput,
|
||||
type IssueConnectorExecutionGrantInput,
|
||||
type LogicalAgentBinding,
|
||||
type ReleaseConnectorLeaseInput,
|
||||
type TakeoverConnectorLeaseInput,
|
||||
} from '@mosaicstack/types';
|
||||
|
||||
export const MAX_CONNECTOR_LEASE_TTL_MS = 5 * 60 * 1000;
|
||||
export const MAX_CONNECTOR_GRANT_TTL_MS = 30 * 1000;
|
||||
|
||||
export interface ConnectorLeaseCoordinatorOptions {
|
||||
readonly now?: () => Date;
|
||||
readonly maxLeaseTtlMs?: number;
|
||||
readonly maxGrantTtlMs?: number;
|
||||
}
|
||||
|
||||
export class ConnectorLeaseError extends Error {
|
||||
constructor(
|
||||
readonly code: ConnectorLeaseRejectReason,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = ConnectorLeaseError.name;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime-neutral lease coordinator. Durable CAS lives in the store adapter;
|
||||
* grant provenance remains process-local so a restart fails closed and mints
|
||||
* fresh grants from the durable current lease.
|
||||
*/
|
||||
export class ConnectorLeaseCoordinator {
|
||||
private readonly issuedGrants = new WeakSet<object>();
|
||||
private readonly now: () => Date;
|
||||
private readonly maxLeaseTtlMs: number;
|
||||
private readonly maxGrantTtlMs: number;
|
||||
|
||||
constructor(
|
||||
private readonly store: ConnectorLeaseStore,
|
||||
options: ConnectorLeaseCoordinatorOptions = {},
|
||||
) {
|
||||
this.now = options.now ?? (() => new Date());
|
||||
this.maxLeaseTtlMs = normalizeTtlLimit(
|
||||
options.maxLeaseTtlMs ?? MAX_CONNECTOR_LEASE_TTL_MS,
|
||||
MAX_CONNECTOR_LEASE_TTL_MS,
|
||||
'lease',
|
||||
);
|
||||
this.maxGrantTtlMs = normalizeTtlLimit(
|
||||
options.maxGrantTtlMs ?? MAX_CONNECTOR_GRANT_TTL_MS,
|
||||
MAX_CONNECTOR_GRANT_TTL_MS,
|
||||
'grant',
|
||||
);
|
||||
}
|
||||
|
||||
async acquire(input: AcquireConnectorLeaseInput): Promise<ConnectorLease> {
|
||||
const command = normalizeAcquireInput(input, this.maxLeaseTtlMs);
|
||||
const now = this.now();
|
||||
return this.store.acquire({
|
||||
...command,
|
||||
leaseId: randomUUID(),
|
||||
now: now.toISOString(),
|
||||
expiresAt: expiresAt(now, command.ttlMs),
|
||||
});
|
||||
}
|
||||
|
||||
async takeover(input: TakeoverConnectorLeaseInput): Promise<ConnectorLease> {
|
||||
const command = normalizeAcquireInput(input, this.maxLeaseTtlMs);
|
||||
const now = this.now();
|
||||
return this.store.takeover({
|
||||
...command,
|
||||
expectedEpoch: normalizeLeaseEpoch(input.expectedEpoch),
|
||||
leaseId: randomUUID(),
|
||||
now: now.toISOString(),
|
||||
expiresAt: expiresAt(now, command.ttlMs),
|
||||
});
|
||||
}
|
||||
|
||||
async heartbeat(input: HeartbeatConnectorLeaseInput): Promise<ConnectorLease> {
|
||||
const now = this.now();
|
||||
const lease = normalizeConnectorLease(input.lease);
|
||||
const ttlMs = normalizeTtl(input.ttlMs, this.maxLeaseTtlMs, 'lease');
|
||||
return this.store.heartbeat({
|
||||
lease,
|
||||
ttlMs,
|
||||
correlationId: normalizeCorrelationId(input.correlationId),
|
||||
now: now.toISOString(),
|
||||
expiresAt: expiresAt(now, ttlMs),
|
||||
});
|
||||
}
|
||||
|
||||
async release(input: ReleaseConnectorLeaseInput): Promise<void> {
|
||||
await this.store.release({
|
||||
lease: normalizeConnectorLease(input.lease),
|
||||
correlationId: normalizeCorrelationId(input.correlationId),
|
||||
now: this.now().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
async current(binding: LogicalAgentBinding): Promise<ConnectorLease | null> {
|
||||
return this.store.findCurrent(normalizeBinding(binding));
|
||||
}
|
||||
|
||||
async issueGrant(input: IssueConnectorExecutionGrantInput): Promise<ConnectorExecutionGrant> {
|
||||
const now = this.now();
|
||||
const lease = normalizeConnectorLease(input.lease);
|
||||
const scopes = normalizeConnectorScopes(input.scopes);
|
||||
const correlationId = normalizeCorrelationId(input.correlationId);
|
||||
const ttlMs = normalizeTtl(input.ttlMs, this.maxGrantTtlMs, 'grant');
|
||||
const current = await this.store.findCurrent(lease);
|
||||
await this.assertCurrentLease(current, lease, now, correlationId);
|
||||
if (!current) throw new ConnectorLeaseError('lease_missing', 'Connector lease is unavailable');
|
||||
if (!isScopeSubset(scopes, lease.scopes) || !isScopeSubset(scopes, current.scopes)) {
|
||||
await this.reject(lease, correlationId, now, 'scope_denied');
|
||||
}
|
||||
const requestedExpiry = new Date(now.getTime() + ttlMs);
|
||||
const leaseExpiry = new Date(current.expiresAt);
|
||||
const grantExpiry = requestedExpiry < leaseExpiry ? requestedExpiry : leaseExpiry;
|
||||
const grant: ConnectorExecutionGrant = Object.freeze({
|
||||
identity: current.identity,
|
||||
bindingId: current.bindingId,
|
||||
leaseId: current.leaseId,
|
||||
connectorId: current.connectorId,
|
||||
scopes,
|
||||
leaseEpoch: current.leaseEpoch,
|
||||
issuedAt: now.toISOString(),
|
||||
expiresAt: grantExpiry.toISOString(),
|
||||
correlationId,
|
||||
});
|
||||
this.issuedGrants.add(grant);
|
||||
return grant;
|
||||
}
|
||||
|
||||
async executeGrant<TInput, TOutput>(
|
||||
grant: ConnectorExecutionGrant,
|
||||
requiredScope: string,
|
||||
input: TInput,
|
||||
adapter: FencedConnectorAdapter<TInput, TOutput>,
|
||||
): Promise<TOutput> {
|
||||
const now = this.now();
|
||||
const normalizedScope = normalizeConnectorScope(requiredScope);
|
||||
if (!this.issuedGrants.has(grant)) {
|
||||
await this.rejectForgedGrant(grant, now);
|
||||
}
|
||||
if (new Date(grant.expiresAt) <= now) {
|
||||
await this.rejectGrant(grant, now, 'grant_expired');
|
||||
}
|
||||
const current = await this.store.findCurrent(normalizeBinding(grant));
|
||||
await this.assertCurrentLease(current, grant, now, grant.correlationId);
|
||||
if (!grant.scopes.includes(normalizedScope) || !current?.scopes.includes(normalizedScope)) {
|
||||
await this.rejectGrant(grant, now, 'scope_denied');
|
||||
}
|
||||
if (!current) throw new ConnectorLeaseError('lease_missing', 'Connector lease is unavailable');
|
||||
const context: ConnectorExecutionContext = Object.freeze({
|
||||
identity: current.identity,
|
||||
bindingId: current.bindingId,
|
||||
leaseId: current.leaseId,
|
||||
connectorId: current.connectorId,
|
||||
scopes: Object.freeze([...grant.scopes]),
|
||||
leaseEpoch: current.leaseEpoch,
|
||||
correlationId: grant.correlationId,
|
||||
grantExpiresAt: grant.expiresAt,
|
||||
});
|
||||
return adapter.execute(input, context);
|
||||
}
|
||||
|
||||
private async assertCurrentLease(
|
||||
current: ConnectorLease | null,
|
||||
authority: ConnectorLease | ConnectorExecutionGrant,
|
||||
now: Date,
|
||||
correlationId: string,
|
||||
): Promise<void> {
|
||||
if (!current) await this.reject(authority, correlationId, now, 'lease_missing');
|
||||
if (!current) throw new ConnectorLeaseError('lease_missing', 'Connector lease is unavailable');
|
||||
if (current.releasedAt) await this.reject(authority, correlationId, now, 'lease_released');
|
||||
if (new Date(current.expiresAt) <= now) {
|
||||
await this.store.recordAudit(auditEvent(current, correlationId, now, 'expiry', 'succeeded'));
|
||||
await this.reject(authority, correlationId, now, 'lease_expired');
|
||||
}
|
||||
if (current.leaseEpoch !== authority.leaseEpoch) {
|
||||
await this.reject(authority, correlationId, now, 'stale_epoch');
|
||||
}
|
||||
if (current.leaseId !== authority.leaseId || current.connectorId !== authority.connectorId) {
|
||||
await this.reject(authority, correlationId, now, 'connector_mismatch');
|
||||
}
|
||||
}
|
||||
|
||||
private async rejectGrant(
|
||||
grant: ConnectorExecutionGrant,
|
||||
now: Date,
|
||||
reason: ConnectorLeaseRejectReason,
|
||||
): Promise<never> {
|
||||
return this.reject(grant, grant.correlationId, now, reason);
|
||||
}
|
||||
|
||||
private async rejectForgedGrant(grant: unknown, now: Date): Promise<never> {
|
||||
const event = safeForgedGrantAudit(grant, now);
|
||||
await this.store.recordAudit(event);
|
||||
throw new ConnectorLeaseError('forged_grant', safeReasonMessage('forged_grant'));
|
||||
}
|
||||
|
||||
private async reject(
|
||||
authority: LogicalAgentBinding & {
|
||||
readonly connectorId: string;
|
||||
readonly leaseId?: string;
|
||||
readonly leaseEpoch?: string;
|
||||
},
|
||||
correlationId: string,
|
||||
now: Date,
|
||||
reason: ConnectorLeaseRejectReason,
|
||||
): Promise<never> {
|
||||
await this.store.recordAudit(
|
||||
auditEvent(authority, correlationId, now, 'reject', 'denied', reason),
|
||||
);
|
||||
throw new ConnectorLeaseError(reason, safeReasonMessage(reason));
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeAcquireInput(
|
||||
input: AcquireConnectorLeaseInput,
|
||||
maxLeaseTtlMs: number,
|
||||
): AcquireConnectorLeaseInput {
|
||||
return {
|
||||
identity: normalizeLogicalAgentIdentity(input.identity),
|
||||
bindingId: normalizeLogicalBindingId(input.bindingId),
|
||||
connectorId: normalizeConnectorId(input.connectorId),
|
||||
scopes: normalizeConnectorScopes(input.scopes),
|
||||
ttlMs: normalizeTtl(input.ttlMs, maxLeaseTtlMs, 'lease'),
|
||||
correlationId: normalizeCorrelationId(input.correlationId),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeBinding(input: LogicalAgentBinding): LogicalAgentBinding {
|
||||
return {
|
||||
identity: normalizeLogicalAgentIdentity(input.identity),
|
||||
bindingId: normalizeLogicalBindingId(input.bindingId),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeConnectorLease(lease: ConnectorLease): ConnectorLease {
|
||||
const binding = normalizeBinding(lease);
|
||||
return Object.freeze({
|
||||
...binding,
|
||||
leaseId: lease.leaseId,
|
||||
connectorId: normalizeConnectorId(lease.connectorId),
|
||||
scopes: normalizeConnectorScopes(lease.scopes),
|
||||
leaseEpoch: normalizeLeaseEpoch(lease.leaseEpoch),
|
||||
acquiredAt: normalizeTimestamp(lease.acquiredAt, 'lease acquisition'),
|
||||
heartbeatAt: normalizeTimestamp(lease.heartbeatAt, 'lease heartbeat'),
|
||||
expiresAt: normalizeTimestamp(lease.expiresAt, 'lease expiry'),
|
||||
...(lease.releasedAt
|
||||
? { releasedAt: normalizeTimestamp(lease.releasedAt, 'lease release') }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeTtl(ttlMs: number, maximum: number, kind: 'lease' | 'grant'): number {
|
||||
if (!Number.isSafeInteger(ttlMs) || ttlMs <= 0 || ttlMs > maximum) {
|
||||
throw new Error(
|
||||
`Connector ${kind} TTL must be a positive safe integer no greater than ${maximum}ms`,
|
||||
);
|
||||
}
|
||||
return ttlMs;
|
||||
}
|
||||
|
||||
function normalizeTtlLimit(ttlMs: number, hardMaximum: number, kind: 'lease' | 'grant'): number {
|
||||
if (!Number.isSafeInteger(ttlMs) || ttlMs <= 0 || ttlMs > hardMaximum) {
|
||||
throw new Error(
|
||||
`Maximum connector ${kind} TTL must be a positive safe integer no greater than ${hardMaximum}ms`,
|
||||
);
|
||||
}
|
||||
return ttlMs;
|
||||
}
|
||||
|
||||
function normalizeTimestamp(value: string, label: string): string {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) throw new Error(`${label} timestamp is invalid`);
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
function expiresAt(now: Date, ttlMs: number): string {
|
||||
const expiry = new Date(now.getTime() + ttlMs);
|
||||
if (Number.isNaN(expiry.getTime())) throw new Error('Connector lease TTL exceeds date range');
|
||||
return expiry.toISOString();
|
||||
}
|
||||
|
||||
function isScopeSubset(requested: readonly string[], allowed: readonly string[]): boolean {
|
||||
return requested.every((scope) => allowed.includes(scope));
|
||||
}
|
||||
|
||||
function auditEvent(
|
||||
authority: LogicalAgentBinding & {
|
||||
readonly connectorId: string;
|
||||
readonly leaseId?: string;
|
||||
readonly leaseEpoch?: string;
|
||||
},
|
||||
correlationId: string,
|
||||
now: Date,
|
||||
event: ConnectorLeaseAuditEvent['event'],
|
||||
outcome: ConnectorLeaseAuditEvent['outcome'],
|
||||
reason?: ConnectorLeaseRejectReason,
|
||||
): ConnectorLeaseAuditEvent {
|
||||
return {
|
||||
identity: authority.identity,
|
||||
bindingId: authority.bindingId,
|
||||
connectorId: authority.connectorId,
|
||||
correlationId,
|
||||
occurredAt: now.toISOString(),
|
||||
event,
|
||||
outcome,
|
||||
...(authority.leaseId ? { leaseId: authority.leaseId } : {}),
|
||||
...(authority.leaseEpoch ? { leaseEpoch: authority.leaseEpoch } : {}),
|
||||
...(reason ? { reason } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function safeForgedGrantAudit(grant: unknown, now: Date): ConnectorLeaseAuditEvent {
|
||||
const fallback: ConnectorLeaseAuditEvent = {
|
||||
identity: { tenantId: 'untrusted', logicalAgentId: 'untrusted' },
|
||||
bindingId: 'untrusted',
|
||||
connectorId: 'untrusted',
|
||||
correlationId: 'untrusted',
|
||||
occurredAt: now.toISOString(),
|
||||
event: 'reject',
|
||||
outcome: 'denied',
|
||||
reason: 'forged_grant',
|
||||
};
|
||||
if (typeof grant !== 'object' || grant === null || !('identity' in grant)) return fallback;
|
||||
const identity = grant.identity;
|
||||
if (typeof identity !== 'object' || identity === null) return fallback;
|
||||
if (!('tenantId' in identity) || !('logicalAgentId' in identity)) return fallback;
|
||||
if (!('bindingId' in grant) || !('connectorId' in grant) || !('correlationId' in grant)) {
|
||||
return fallback;
|
||||
}
|
||||
if (
|
||||
typeof identity.tenantId !== 'string' ||
|
||||
typeof identity.logicalAgentId !== 'string' ||
|
||||
typeof grant.bindingId !== 'string' ||
|
||||
typeof grant.connectorId !== 'string' ||
|
||||
typeof grant.correlationId !== 'string'
|
||||
) {
|
||||
return fallback;
|
||||
}
|
||||
try {
|
||||
return {
|
||||
identity: normalizeLogicalAgentIdentity({
|
||||
tenantId: identity.tenantId,
|
||||
logicalAgentId: identity.logicalAgentId,
|
||||
}),
|
||||
bindingId: normalizeLogicalBindingId(grant.bindingId),
|
||||
connectorId: normalizeConnectorId(grant.connectorId),
|
||||
correlationId: normalizeCorrelationId(grant.correlationId),
|
||||
occurredAt: now.toISOString(),
|
||||
event: 'reject',
|
||||
outcome: 'denied',
|
||||
reason: 'forged_grant',
|
||||
};
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function safeReasonMessage(reason: ConnectorLeaseRejectReason): string {
|
||||
return `Connector authority denied: ${reason}`;
|
||||
}
|
||||
@@ -5,4 +5,3 @@ export * from './tmux-fleet-runtime-provider.js';
|
||||
export * from './hermes-runtime-provider.js';
|
||||
export * from './matrix-native-runtime-provider.js';
|
||||
export * from './durable-session.js';
|
||||
export * from './connector-lease.js';
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
CREATE TABLE "connector_lease_audit_log" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"tenant_id" text NOT NULL,
|
||||
"logical_agent_id" text NOT NULL,
|
||||
"binding_id" text NOT NULL,
|
||||
"connector_id" text NOT NULL,
|
||||
"lease_id" uuid,
|
||||
"lease_epoch" bigint,
|
||||
"event" text NOT NULL,
|
||||
"outcome" text NOT NULL,
|
||||
"reason" text,
|
||||
"correlation_id" text NOT NULL,
|
||||
"occurred_at" timestamp with time zone NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "logical_agent_connector_leases" (
|
||||
"lease_id" uuid PRIMARY KEY NOT NULL,
|
||||
"tenant_id" text NOT NULL,
|
||||
"logical_agent_id" text NOT NULL,
|
||||
"binding_id" text NOT NULL,
|
||||
"connector_id" text NOT NULL,
|
||||
"scopes" jsonb NOT NULL,
|
||||
"lease_epoch" bigint NOT NULL,
|
||||
"acquired_at" timestamp with time zone NOT NULL,
|
||||
"heartbeat_at" timestamp with time zone NOT NULL,
|
||||
"expires_at" timestamp with time zone NOT NULL,
|
||||
"released_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX "connector_lease_audit_binding_occurred_idx" ON "connector_lease_audit_log" USING btree ("tenant_id","logical_agent_id","binding_id","occurred_at" DESC NULLS LAST);--> statement-breakpoint
|
||||
CREATE INDEX "connector_lease_audit_correlation_idx" ON "connector_lease_audit_log" USING btree ("correlation_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "logical_agent_connector_lease_binding_idx" ON "logical_agent_connector_leases" USING btree ("tenant_id","logical_agent_id","binding_id");--> statement-breakpoint
|
||||
CREATE INDEX "logical_agent_connector_lease_expiry_idx" ON "logical_agent_connector_leases" USING btree ("expires_at");--> statement-breakpoint
|
||||
CREATE INDEX "logical_agent_connector_lease_connector_idx" ON "logical_agent_connector_leases" USING btree ("connector_id");
|
||||
File diff suppressed because it is too large
Load Diff
@@ -113,13 +113,6 @@
|
||||
"when": 1783942610000,
|
||||
"tag": "0015_interaction_checkpoint_payload_digest",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 16,
|
||||
"version": "7",
|
||||
"when": 1784050648841,
|
||||
"tag": "0016_salty_morlocks",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
uniqueIndex,
|
||||
real,
|
||||
integer,
|
||||
bigint,
|
||||
customType,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
|
||||
@@ -488,68 +487,6 @@ export const agentLogs = pgTable(
|
||||
],
|
||||
);
|
||||
|
||||
// ─── Logical agent connector authority ──────────────────────────────────────
|
||||
// One durable row is the current authority for a tenant/logical-agent/binding.
|
||||
// Runtime-native session identifiers never enter these core tables.
|
||||
|
||||
export const logicalAgentConnectorLeases = pgTable(
|
||||
'logical_agent_connector_leases',
|
||||
{
|
||||
leaseId: uuid('lease_id').primaryKey(),
|
||||
tenantId: text('tenant_id').notNull(),
|
||||
logicalAgentId: text('logical_agent_id').notNull(),
|
||||
bindingId: text('binding_id').notNull(),
|
||||
connectorId: text('connector_id').notNull(),
|
||||
scopes: jsonb('scopes').notNull().$type<string[]>(),
|
||||
leaseEpoch: bigint('lease_epoch', { mode: 'bigint' }).notNull(),
|
||||
acquiredAt: timestamp('acquired_at', { withTimezone: true }).notNull(),
|
||||
heartbeatAt: timestamp('heartbeat_at', { withTimezone: true }).notNull(),
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
||||
releasedAt: timestamp('released_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('logical_agent_connector_lease_binding_idx').on(
|
||||
t.tenantId,
|
||||
t.logicalAgentId,
|
||||
t.bindingId,
|
||||
),
|
||||
index('logical_agent_connector_lease_expiry_idx').on(t.expiresAt),
|
||||
index('logical_agent_connector_lease_connector_idx').on(t.connectorId),
|
||||
],
|
||||
);
|
||||
|
||||
/** Append-only, credential-safe lease lifecycle and fencing denial metadata. */
|
||||
export const connectorLeaseAuditLog = pgTable(
|
||||
'connector_lease_audit_log',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
tenantId: text('tenant_id').notNull(),
|
||||
logicalAgentId: text('logical_agent_id').notNull(),
|
||||
bindingId: text('binding_id').notNull(),
|
||||
connectorId: text('connector_id').notNull(),
|
||||
leaseId: uuid('lease_id'),
|
||||
leaseEpoch: bigint('lease_epoch', { mode: 'bigint' }),
|
||||
event: text('event', {
|
||||
enum: ['acquire', 'renew', 'takeover', 'reject', 'release', 'expiry'],
|
||||
}).notNull(),
|
||||
outcome: text('outcome', { enum: ['succeeded', 'denied'] }).notNull(),
|
||||
reason: text('reason'),
|
||||
correlationId: text('correlation_id').notNull(),
|
||||
occurredAt: timestamp('occurred_at', { withTimezone: true }).notNull(),
|
||||
},
|
||||
(t) => [
|
||||
index('connector_lease_audit_binding_occurred_idx').on(
|
||||
t.tenantId,
|
||||
t.logicalAgentId,
|
||||
t.bindingId,
|
||||
t.occurredAt.desc(),
|
||||
),
|
||||
index('connector_lease_audit_correlation_idx').on(t.correlationId),
|
||||
],
|
||||
);
|
||||
|
||||
// ─── Tess durable session state ─────────────────────────────────────────────
|
||||
// PostgreSQL is canonical for restart-safe Tess session recovery. The state
|
||||
// machine lives in @mosaicstack/agent; these records are its durable adapter.
|
||||
|
||||
@@ -9,8 +9,7 @@ package, normally at:
|
||||
```
|
||||
|
||||
The default tmux socket is `mosaic-fleet` so fleet commands do not touch the
|
||||
default tmux server. The roster is the desired-state authority; generated environment files are
|
||||
rebuildable projections, never a second source of configuration.
|
||||
default tmux server.
|
||||
|
||||
## Examples
|
||||
|
||||
@@ -32,17 +31,6 @@ The installed `tools/fleet/print-interaction-effective-policy.sh` prints only
|
||||
the resolved name, runtime, model, reasoning, and tool policy. It never reads
|
||||
or prints credential variables.
|
||||
|
||||
## Generated agent environment boundary
|
||||
|
||||
`mosaic fleet install` writes a private deterministic projection at
|
||||
`~/.config/mosaic/fleet/agents/<agent>.env.generated`. It may relocate only approved local machine
|
||||
data to `<agent>.env.local`; generated keys, arbitrary commands, secret-like keys, duplicate keys,
|
||||
unknown keys, and unsafe permissions fail before a tmux session is created. Legacy `.env` input is
|
||||
regenerated, relocated, or quarantined and is not a launch authority.
|
||||
|
||||
See [`docs/fleet/reference/generated-env-boundary.md`](../../../../docs/fleet/reference/generated-env-boundary.md)
|
||||
for allowed local keys and the USC downstream interface evidence.
|
||||
|
||||
Initialize a roster:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -12,22 +12,19 @@ on demand. Engineering personas have no explicit `domain:` marker (they are the
|
||||
implicit `engineering` domain); cross-domain personas carry a `domain:` key in
|
||||
their intro so tooling can group them.
|
||||
|
||||
> This file is an index, not an authority source. The fleet persona resolver reads
|
||||
> its rows for discovery compatibility, then requires a readable `*.md` contract;
|
||||
> authority is derived from canonical class metadata in code, never from this prose.
|
||||
> This file is an index only — no code imports it. To add a persona, drop a new
|
||||
> `*.md` next to the others (mirroring the existing structure) and add a row here.
|
||||
|
||||
## engineering
|
||||
|
||||
| Persona | Purpose |
|
||||
| --------------- | ------------------------------------------------------------------------------ |
|
||||
| orchestrator | Always-on coordinator — runs the supervisor loop, dispatches ready work |
|
||||
| team-leader | Coordinates only orchestrator-leased capacity for one bounded project |
|
||||
| board | Multi-lens deliberation panel; owns the mission's direction, not its execution |
|
||||
| planner | Turns ratified objectives into a phased FR plan wired into a `depends_on` DAG |
|
||||
| decomposition | Splits FRs into one-PR-each cards wired with `depends_on` edges |
|
||||
| code | Primary executor — one card, one branch, one PR to green CI |
|
||||
| review | Correctness reviewer — judges an open PR on correctness, scope, and coverage |
|
||||
| validator | Independent final evidence certificate; never approves-to-land or merges |
|
||||
| security-review | Second line of review — secrets, auth, and forbidden-path safety |
|
||||
| site-tester | Runtime verifier — runs the change and checks behavior vs. acceptance criteria |
|
||||
| documentation | Prose maintainer — keeps human-facing docs and projections in sync |
|
||||
@@ -36,7 +33,6 @@ their intro so tooling can group them.
|
||||
| operator | Escalation and control surface — owns exceptions and the fleet pause switch |
|
||||
| session-review | Post-task retrospective — turns finished work into improvement signals |
|
||||
| enhancer | Continuous-improvement loop — upgrades the fleet's tools, skills, and harness |
|
||||
| interaction | Operator request/status surface; routes orchestration and merge decisions |
|
||||
|
||||
## executive
|
||||
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
# Interaction — fleet role definition
|
||||
|
||||
The **interaction** role (`class: interaction`) is the operator-facing request and status surface for Mosaic.
|
||||
|
||||
## Mandate
|
||||
|
||||
1. Receive operator requests and present observable fleet or runtime status.
|
||||
2. Route orchestration requests to the orchestrator and merge decisions to the merge-gate.
|
||||
3. Report supported actions and their outcomes without claiming another role's authority.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- Request/status only; it does not orchestrate, issue leases, approve-to-land, or merge.
|
||||
- It does not mutate roster configuration, role authority, or credentials.
|
||||
- A configured instance name such as Tess is display data, never a class or authority source.
|
||||
- `operator-interaction` remains a compatibility alias for this canonical class.
|
||||
@@ -1,16 +0,0 @@
|
||||
# Team leader — fleet role definition
|
||||
|
||||
The **team-leader** (`class: team-leader`) coordinates a bounded project team using only capacity granted by an orchestrator-issued lease.
|
||||
|
||||
## Mandate
|
||||
|
||||
1. Direct the leased coder, reviewer, and validator capacity for the assigned project scope.
|
||||
2. Track delivery status and return results or blockers to the orchestrator.
|
||||
3. Stop using capacity when the lease or assignment ends.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- Leased capacity only; this role does not issue or expand its own lease.
|
||||
- It cannot change fleet roster membership, role authority, fleet configuration, or credentials.
|
||||
- It cannot approve-to-land or merge.
|
||||
- It does not displace the orchestrator's topology and lease authority.
|
||||
@@ -1,16 +0,0 @@
|
||||
# Validator — fleet role definition
|
||||
|
||||
The **validator** (`class: validator`) is the independent final evidence seat. It examines the accepted requirements, test evidence, review record, and candidate head and may issue a validation certificate for that exact evidence set.
|
||||
|
||||
## Mandate
|
||||
|
||||
1. Validate acceptance evidence independently from the implementation author.
|
||||
2. Issue or withhold a final validation certificate for the reviewed candidate.
|
||||
3. Report missing, stale, or contradictory evidence without altering it.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- **Certificate only:** the validator does not approve-to-land or merge.
|
||||
- It does not replace correctness or security review.
|
||||
- It does not write product code, mutate the roster, issue leases, or access credentials.
|
||||
- A configured instance name such as Ultron is display data, never a class or authority source.
|
||||
@@ -24,56 +24,45 @@ The agent template calls:
|
||||
|
||||
which starts or reuses a tmux session on `MOSAIC_TMUX_SOCKET`.
|
||||
|
||||
## Generated environment and local data
|
||||
## Local customization
|
||||
|
||||
The roster-derived projection is written outside the package at:
|
||||
Per-agent overrides live outside the package in:
|
||||
|
||||
```text
|
||||
~/.config/mosaic/fleet/agents/<agent>.env.generated
|
||||
~/.config/mosaic/fleet/agents/<agent>.env
|
||||
```
|
||||
|
||||
Systemd does not read either environment file. It starts the launcher with a fixed cleared bootstrap
|
||||
environment; before it creates, queries, or stops an exact agent tmux session, `start-agent-session.sh`
|
||||
strictly parses the generated projection and the optional local data file:
|
||||
Example:
|
||||
|
||||
```text
|
||||
~/.config/mosaic/fleet/agents/<agent>.env.local
|
||||
```dotenv
|
||||
MOSAIC_TMUX_SOCKET=mosaic-fleet
|
||||
MOSAIC_AGENT_RUNTIME=claude
|
||||
MOSAIC_AGENT_WORKDIR=$HOME/src/your-project
|
||||
# Optional escape hatch for PoC/canary agents:
|
||||
# MOSAIC_AGENT_COMMAND=mosaic yolo claude
|
||||
```
|
||||
|
||||
The local file may contain only safe machine-specific data (`MOSAIC_RUNTIME_BIN`, heartbeat paths or
|
||||
interval, and Claude configuration paths). It cannot override roster-derived keys, carry a command,
|
||||
or contain secret-like/unknown keys. Both files must be private regular files. Do not hand-edit the
|
||||
generated projection; update the roster and regenerate it instead. A legacy `<agent>.env` is
|
||||
consumed only for regeneration, strict relocation, or private quarantine and is never launch input.
|
||||
|
||||
See `docs/fleet/reference/generated-env-boundary.md` for the full contract.
|
||||
|
||||
## Manual canary sequence
|
||||
|
||||
Use the roster and the supported installer; do not pre-create the agent environment directory or
|
||||
edit a generated projection. `mosaic fleet install` validates the roster, installs the units and
|
||||
helpers, and writes private roster-derived projections before any service is started.
|
||||
|
||||
```bash
|
||||
# Create a site-owned canary roster. Inspect an existing roster before using --force.
|
||||
mosaic fleet init --profile minimal --write
|
||||
mosaic fleet install
|
||||
mkdir -p ~/.config/systemd/user ~/.config/mosaic/tools/fleet ~/.config/mosaic/fleet/agents
|
||||
cp packages/mosaic/framework/systemd/user/mosaic-*.service ~/.config/systemd/user/
|
||||
cp packages/mosaic/framework/tools/fleet/*.sh ~/.config/mosaic/tools/fleet/
|
||||
chmod +x ~/.config/mosaic/tools/fleet/*.sh
|
||||
systemctl --user daemon-reload
|
||||
mosaic fleet start canary-pi
|
||||
systemctl --user start mosaic-tmux-holder.service
|
||||
systemctl --user start mosaic-agent@canary.service
|
||||
tmux -L mosaic-fleet ls
|
||||
```
|
||||
|
||||
For an operator-interaction service, first put `<agent-name>` in the roster with the pinned Pi
|
||||
runtime, model, reasoning, and `operator-interaction` tool policy. Re-run `mosaic fleet install` after
|
||||
that roster change so it writes `<agent-name>.env.generated`; ambient `MOSAIC_AGENT_*` values are not
|
||||
launch authority. The generic unit instance uses that generated identity, and no service source is
|
||||
renamed for an instance:
|
||||
|
||||
```bash
|
||||
mosaic fleet install
|
||||
systemctl --user daemon-reload
|
||||
# For an operator-interaction service, the roster/env identity selects the
|
||||
# generic unit instance; no service source is renamed for an instance.
|
||||
systemctl --user start mosaic-interaction-agent@<agent-name>.service
|
||||
~/.config/mosaic/tools/fleet/print-interaction-effective-policy.sh <agent-name>
|
||||
MOSAIC_AGENT_NAME=<agent-name> \
|
||||
MOSAIC_AGENT_RUNTIME=pi \
|
||||
MOSAIC_AGENT_MODEL=openai/gpt-5.6-sol \
|
||||
MOSAIC_AGENT_REASONING=high \
|
||||
MOSAIC_AGENT_TOOL_POLICY=operator-interaction \
|
||||
~/.config/mosaic/tools/fleet/print-interaction-effective-policy.sh
|
||||
```
|
||||
|
||||
Do not use `tmux kill-server` without `-L mosaic-fleet`; this pattern is meant
|
||||
|
||||
@@ -7,13 +7,16 @@ PartOf=mosaic-tmux-holder.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
# Remove loader and noninteractive-shell controls before ExecStart loads env.
|
||||
UnsetEnvironment=LD_PRELOAD BASH_ENV ENV
|
||||
RemainAfterExit=yes
|
||||
# Never preload the projection. The launcher starts from a fixed minimal
|
||||
# environment and strictly validates generated/local data before tmux effects.
|
||||
ExecStart=/usr/bin/env -i HOME=%h MOSAIC_AGENT_NAME=%i PATH=/usr/bin:/bin /bin/bash --noprofile --norc %h/.config/mosaic/tools/fleet/start-agent-session.sh %i
|
||||
ExecStop=-/usr/bin/env -i HOME=%h MOSAIC_AGENT_NAME=%i PATH=/usr/bin:/bin /bin/bash --noprofile --norc %h/.config/mosaic/tools/fleet/start-agent-session.sh --stop %i
|
||||
# No default MOSAIC_TMUX_SOCKET: an absent roster socket means the literal
|
||||
# default tmux socket (no -L). The per-agent .env sets it when the roster names
|
||||
# one; otherwise it stays unset and start-agent-session.sh uses the default socket.
|
||||
Environment=MOSAIC_AGENT_NAME=%i
|
||||
Environment=MOSAIC_AGENT_RUNTIME=pi
|
||||
Environment=MOSAIC_AGENT_WORKDIR=%h
|
||||
EnvironmentFile=-%h/.config/mosaic/fleet/agents/%i.env
|
||||
ExecStart=/bin/bash %h/.config/mosaic/tools/fleet/start-agent-session.sh %i
|
||||
ExecStop=-/bin/bash -lc 'if [ -n "${MOSAIC_TMUX_SOCKET:-}" ]; then tmux -L "$MOSAIC_TMUX_SOCKET" kill-session -t "=%i"; else tmux kill-session -t "=%i"; fi'
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
|
||||
@@ -7,13 +7,11 @@ PartOf=mosaic-tmux-holder.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
# Remove loader and noninteractive-shell controls before ExecStart loads env.
|
||||
UnsetEnvironment=LD_PRELOAD BASH_ENV ENV
|
||||
RemainAfterExit=yes
|
||||
# The interaction wrapper delegates to the shared strict parser before pinned
|
||||
# profile checks; no projection data reaches Bash through systemd.
|
||||
ExecStart=/usr/bin/env -i HOME=%h MOSAIC_AGENT_NAME=%i PATH=/usr/bin:/bin /bin/bash --noprofile --norc %h/.config/mosaic/tools/fleet/start-interaction-service.sh %i
|
||||
ExecStop=-/usr/bin/env -i HOME=%h MOSAIC_AGENT_NAME=%i PATH=/usr/bin:/bin /bin/bash --noprofile --norc %h/.config/mosaic/tools/fleet/start-agent-session.sh --stop %i
|
||||
Environment=MOSAIC_AGENT_NAME=%i
|
||||
EnvironmentFile=%h/.config/mosaic/fleet/agents/%i.env
|
||||
ExecStart=/bin/bash %h/.config/mosaic/tools/fleet/start-interaction-service.sh %i
|
||||
ExecStop=-/bin/bash -lc 'if [ -n "${MOSAIC_TMUX_SOCKET:-}" ]; then tmux -L "$MOSAIC_TMUX_SOCKET" kill-session -t "=%i"; else tmux kill-session -t "=%i"; fi'
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
|
||||
@@ -6,11 +6,10 @@ After=default.target
|
||||
[Service]
|
||||
Type=oneshot
|
||||
RemainAfterExit=yes
|
||||
# The holder owns the tmux server, so clear loader, shell-control, and stale
|
||||
# manager/session variables before the server process starts.
|
||||
UnsetEnvironment=LD_PRELOAD BASH_ENV ENV
|
||||
ExecStart=/usr/bin/env -i HOME=%h PATH=/usr/bin:/bin MOSAIC_TMUX_SOCKET=mosaic-fleet MOSAIC_TMUX_HOLDER=_holder /bin/bash --noprofile --norc %h/.config/mosaic/tools/fleet/start-tmux-holder.sh
|
||||
ExecStop=-/usr/bin/env -i HOME=%h PATH=/usr/bin:/bin MOSAIC_TMUX_SOCKET=mosaic-fleet /bin/bash --noprofile --norc -c 'tmux -L "$MOSAIC_TMUX_SOCKET" kill-server'
|
||||
Environment=MOSAIC_TMUX_SOCKET=mosaic-fleet
|
||||
Environment=MOSAIC_TMUX_HOLDER=_holder
|
||||
ExecStart=/bin/bash -lc 'tmux -L "$MOSAIC_TMUX_SOCKET" has-session -t "=${MOSAIC_TMUX_HOLDER}:0.0" 2>/dev/null || tmux -L "$MOSAIC_TMUX_SOCKET" new-session -d -s "$MOSAIC_TMUX_HOLDER" "while true; do sleep 3600; done"'
|
||||
ExecStop=-/bin/bash -lc 'tmux -L "$MOSAIC_TMUX_SOCKET" kill-server'
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
|
||||
@@ -5,8 +5,6 @@ SCRIPT_DIR=$(cd -- "$(dirname -- "$0")" && pwd)
|
||||
HOLDER="$SCRIPT_DIR/mosaic-tmux-holder.service"
|
||||
AGENT="$SCRIPT_DIR/mosaic-agent@.service"
|
||||
INTERACTION="$SCRIPT_DIR/mosaic-interaction-agent@.service"
|
||||
HOLDER_START="$SCRIPT_DIR/../../tools/fleet/start-tmux-holder.sh"
|
||||
START_AGENT="$SCRIPT_DIR/../../tools/fleet/start-agent-session.sh"
|
||||
|
||||
fail() {
|
||||
echo "FAIL: $*" >&2
|
||||
@@ -16,40 +14,16 @@ fail() {
|
||||
[ -f "$HOLDER" ] || fail "missing mosaic-tmux-holder.service"
|
||||
[ -f "$AGENT" ] || fail "missing mosaic-agent@.service"
|
||||
[ -f "$INTERACTION" ] || fail "missing mosaic-interaction-agent@.service"
|
||||
[ -x "$HOLDER_START" ] || fail "missing executable start-tmux-holder.sh"
|
||||
[ -x "$START_AGENT" ] || fail "missing executable start-agent-session.sh"
|
||||
|
||||
grep -qF 'ExecStart=' "$HOLDER" || fail "holder has no ExecStart"
|
||||
grep -qF 'tmux -L' "$HOLDER" || fail "holder does not use named tmux socket"
|
||||
grep -qF '_holder' "$HOLDER" || fail "holder session is not explicit"
|
||||
grep -qF 'UnsetEnvironment=LD_PRELOAD BASH_ENV ENV' "$HOLDER" || \
|
||||
fail "holder does not remove loader and shell-control variables"
|
||||
grep -qF 'ExecStart=/usr/bin/env -i HOME=%h PATH=/usr/bin:/bin MOSAIC_TMUX_SOCKET=mosaic-fleet MOSAIC_TMUX_HOLDER=_holder /bin/bash --noprofile --norc %h/.config/mosaic/tools/fleet/start-tmux-holder.sh' "$HOLDER" || \
|
||||
fail "holder does not clear manager environment before starting tmux"
|
||||
grep -qF 'ExecStop=-/usr/bin/env -i HOME=%h PATH=/usr/bin:/bin MOSAIC_TMUX_SOCKET=mosaic-fleet /bin/bash --noprofile --norc -c' "$HOLDER" || \
|
||||
fail "holder stop does not clear manager environment"
|
||||
if grep -qF -- '/bin/bash -lc' "$HOLDER"; then
|
||||
fail "holder must not start tmux through a login shell"
|
||||
fi
|
||||
grep -qF 'Requires=mosaic-tmux-holder.service' "$AGENT" || fail "agent does not require holder"
|
||||
grep -qF 'start-agent-session.sh' "$AGENT" || fail "agent unit does not call start-agent-session.sh"
|
||||
if grep -qE '^Environment(File)?=' "$AGENT" "$INTERACTION"; then
|
||||
fail "agent units must not accept ambient or projection environment before strict parsing"
|
||||
fi
|
||||
grep -qF 'UnsetEnvironment=LD_PRELOAD BASH_ENV ENV' "$AGENT" || \
|
||||
fail "agent unit does not remove loader and shell-control variables"
|
||||
grep -qF 'UnsetEnvironment=LD_PRELOAD BASH_ENV ENV' "$INTERACTION" || \
|
||||
fail "interaction unit does not remove loader and shell-control variables"
|
||||
grep -qF 'ExecStart=/usr/bin/env -i HOME=%h MOSAIC_AGENT_NAME=%i PATH=/usr/bin:/bin /bin/bash --noprofile --norc' "$AGENT" || \
|
||||
fail "agent unit does not clear bootstrap environment before strict parsing"
|
||||
grep -qF 'start-agent-session.sh --stop %i' "$AGENT" || \
|
||||
fail "agent stop does not use the validated exact-stop path"
|
||||
grep -qF 'kill-session -t "=%i"' "$AGENT" || fail "agent stop does not exact-match its session"
|
||||
grep -qF 'Requires=mosaic-tmux-holder.service' "$INTERACTION" || fail "interaction service does not require holder"
|
||||
grep -qF 'ExecStart=/usr/bin/env -i HOME=%h MOSAIC_AGENT_NAME=%i PATH=/usr/bin:/bin /bin/bash --noprofile --norc' "$INTERACTION" || \
|
||||
fail "interaction unit does not clear bootstrap environment before strict parsing"
|
||||
grep -qF 'start-interaction-service.sh %i' "$INTERACTION" || fail "interaction service does not use shared strict parsing"
|
||||
grep -qF 'start-agent-session.sh --stop %i' "$INTERACTION" || \
|
||||
fail "interaction stop does not use the validated exact-stop path"
|
||||
grep -qF 'EnvironmentFile=%h/.config/mosaic/fleet/agents/%i.env' "$INTERACTION" || fail "interaction service does not require per-agent config"
|
||||
grep -qF 'start-interaction-service.sh %i' "$INTERACTION" || fail "interaction service does not validate before startup"
|
||||
|
||||
if command -v systemd-analyze >/dev/null 2>&1; then
|
||||
systemd-analyze verify --user "$HOLDER" "$AGENT" "$INTERACTION" >/tmp/mosaic-fleet-systemd-verify.log 2>&1 || {
|
||||
@@ -58,102 +32,4 @@ if command -v systemd-analyze >/dev/null 2>&1; then
|
||||
}
|
||||
fi
|
||||
|
||||
# Real isolated socket regression: a preexisting server with an LD_PRELOAD
|
||||
# constructor marker must fail closed, while a fresh named server is created.
|
||||
if command -v tmux >/dev/null 2>&1 && command -v cc >/dev/null 2>&1; then
|
||||
TEST_ROOT=$(mktemp -d)
|
||||
TEST_SOCKET="mosaic-holder-test-$$"
|
||||
trap 'tmux -L "$TEST_SOCKET" kill-server >/dev/null 2>&1 || true; rm -rf "$TEST_ROOT"' EXIT
|
||||
MARKER="$TEST_ROOT/loader-marker"
|
||||
LIBRARY="$TEST_ROOT/marker.so"
|
||||
HOLDER_HOME="$TEST_ROOT/holder-home"
|
||||
mkdir -p "$HOLDER_HOME/.config/mosaic/fleet/run"
|
||||
chmod 700 "$HOLDER_HOME/.config" "$HOLDER_HOME/.config/mosaic" \
|
||||
"$HOLDER_HOME/.config/mosaic/fleet" "$HOLDER_HOME/.config/mosaic/fleet/run"
|
||||
printf '123e4567-e89b-12d3-a456-426614174000\n' > \
|
||||
"$HOLDER_HOME/.config/mosaic/fleet/run/holder-owner"
|
||||
chmod 600 "$HOLDER_HOME/.config/mosaic/fleet/run/holder-owner"
|
||||
cat > "$TEST_ROOT/marker.c" <<'EOF'
|
||||
#include <fcntl.h>
|
||||
#include <stdlib.h>
|
||||
#include <unistd.h>
|
||||
__attribute__((constructor)) static void mark_loader(void) {
|
||||
const char *path = getenv("MOSAIC_LOADER_MARKER");
|
||||
if (path != NULL) {
|
||||
int fd = open(path, O_WRONLY | O_CREAT | O_APPEND, 0600);
|
||||
if (fd >= 0) { write(fd, "loaded\\n", 7); close(fd); }
|
||||
}
|
||||
}
|
||||
EOF
|
||||
cc -shared -fPIC -o "$LIBRARY" "$TEST_ROOT/marker.c"
|
||||
MOSAIC_LOADER_MARKER="$MARKER" LD_PRELOAD="$LIBRARY" \
|
||||
tmux -L "$TEST_SOCKET" new-session -d -s _holder 'sleep 60'
|
||||
[ -s "$MARKER" ] || fail "contaminated fixture did not execute loader constructor"
|
||||
server_pid=$(tmux -L "$TEST_SOCKET" display-message -p '#{pid}')
|
||||
: > "$MARKER"
|
||||
if /usr/bin/env -i HOME="$HOLDER_HOME" PATH=/usr/bin:/bin \
|
||||
MOSAIC_TMUX_SOCKET="$TEST_SOCKET" MOSAIC_TMUX_HOLDER=_holder "$HOLDER_START" \
|
||||
>"$TEST_ROOT/holder.out" 2>&1; then
|
||||
fail "holder adopted contaminated named server"
|
||||
fi
|
||||
grep -qF 'global environment does not match the owned-server contract' "$TEST_ROOT/holder.out" || \
|
||||
fail "holder did not report contaminated server environment"
|
||||
[ "$(tmux -L "$TEST_SOCKET" display-message -p '#{pid}')" = "$server_pid" ] || \
|
||||
fail "holder replaced a contaminated server instead of failing closed"
|
||||
[ ! -s "$MARKER" ] || fail "holder execution triggered a contaminated loader"
|
||||
|
||||
# Agent validation must reject the same unmanaged server without cleaning its
|
||||
# global environment or adding a managed session.
|
||||
AGENT_HOME="$HOLDER_HOME/.config/mosaic"
|
||||
AGENT_NAME=loader-safe
|
||||
AGENT_WORKDIR="$AGENT_HOME/work"
|
||||
AGENT_BIN="$TEST_ROOT/agent-bin"
|
||||
mkdir -p "$AGENT_HOME/fleet/agents" "$AGENT_WORKDIR" "$AGENT_BIN"
|
||||
chmod 700 "$AGENT_HOME/fleet/agents"
|
||||
cat > "$AGENT_HOME/fleet/agents/$AGENT_NAME.env.generated" <<EOF
|
||||
MOSAIC_AGENT_NAME=$AGENT_NAME
|
||||
MOSAIC_AGENT_CLASS=code
|
||||
MOSAIC_AGENT_RUNTIME=pi
|
||||
MOSAIC_AGENT_MODEL=
|
||||
MOSAIC_AGENT_REASONING=
|
||||
MOSAIC_AGENT_TOOL_POLICY=code
|
||||
MOSAIC_AGENT_WORKDIR=$AGENT_WORKDIR
|
||||
MOSAIC_TMUX_SOCKET=$TEST_SOCKET
|
||||
EOF
|
||||
printf 'MOSAIC_RUNTIME_BIN=%s\n' "$AGENT_BIN" > "$AGENT_HOME/fleet/agents/$AGENT_NAME.env.local"
|
||||
chmod 600 "$AGENT_HOME/fleet/agents/$AGENT_NAME.env.generated" \
|
||||
"$AGENT_HOME/fleet/agents/$AGENT_NAME.env.local"
|
||||
cat > "$AGENT_BIN/mosaic" <<'EOF'
|
||||
#!/bin/sh
|
||||
sleep 30
|
||||
EOF
|
||||
chmod 700 "$AGENT_BIN/mosaic"
|
||||
server_environment_before=$(tmux -L "$TEST_SOCKET" show-environment -g | sort)
|
||||
server_sessions_before=$(tmux -L "$TEST_SOCKET" list-sessions | sort)
|
||||
if /usr/bin/env -i HOME="$HOLDER_HOME" PATH=/usr/bin:/bin MOSAIC_HOME="$AGENT_HOME" \
|
||||
"$START_AGENT" "$AGENT_NAME" >"$TEST_ROOT/agent.out" 2>&1; then
|
||||
fail "agent launcher adopted contaminated named server"
|
||||
fi
|
||||
[ "$(tmux -L "$TEST_SOCKET" display-message -p '#{pid}')" = "$server_pid" ] || \
|
||||
fail "agent launcher changed unmanaged server PID"
|
||||
[ "$(tmux -L "$TEST_SOCKET" show-environment -g | sort)" = "$server_environment_before" ] || \
|
||||
fail "agent launcher changed unmanaged global environment"
|
||||
[ "$(tmux -L "$TEST_SOCKET" list-sessions | sort)" = "$server_sessions_before" ] || \
|
||||
fail "agent launcher changed unmanaged sessions"
|
||||
tmux -L "$TEST_SOCKET" kill-server
|
||||
/usr/bin/env -i HOME="$HOLDER_HOME" PATH=/usr/bin:/bin \
|
||||
MOSAIC_TMUX_SOCKET="$TEST_SOCKET" MOSAIC_TMUX_HOLDER=_holder "$HOLDER_START"
|
||||
tmux -L "$TEST_SOCKET" has-session -t '=_holder:0.0' || fail "fresh holder was not created"
|
||||
if tmux -L "$TEST_SOCKET" show-environment -g LD_PRELOAD 2>/dev/null | grep -q '^LD_PRELOAD='; then
|
||||
fail "fresh holder retained LD_PRELOAD"
|
||||
fi
|
||||
/usr/bin/env -i HOME="$HOLDER_HOME" PATH=/usr/bin:/bin MOSAIC_HOME="$AGENT_HOME" \
|
||||
"$START_AGENT" "$AGENT_NAME"
|
||||
tmux -L "$TEST_SOCKET" has-session -t "=$AGENT_NAME:0.0" || \
|
||||
fail "agent did not launch on a valid owned server"
|
||||
tmux -L "$TEST_SOCKET" kill-server
|
||||
trap - EXIT
|
||||
rm -rf "$TEST_ROOT"
|
||||
fi
|
||||
|
||||
echo "ok - fleet systemd unit templates"
|
||||
|
||||
@@ -1,207 +1,39 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# FCM-M2-001 boundary: only a roster-derived .env.generated projection and a
|
||||
# separately parsed data-only .env.local can influence launch. Never source an
|
||||
# environment file and never accept a command string from either file.
|
||||
AGENT_NAME=${1:-${MOSAIC_AGENT_NAME:-}}
|
||||
# Absent socket ⇒ the LITERAL default tmux socket (no -L). The roster's
|
||||
# socket_name is honored when set; absent never silently becomes mosaic-fleet
|
||||
# (spawn stays consistent with the onboarding cheat-sheet + fleet ps observe).
|
||||
MOSAIC_TMUX_SOCKET=${MOSAIC_TMUX_SOCKET:-}
|
||||
MOSAIC_AGENT_RUNTIME=${MOSAIC_AGENT_RUNTIME:-pi}
|
||||
MOSAIC_AGENT_MODEL=${MOSAIC_AGENT_MODEL:-}
|
||||
MOSAIC_AGENT_REASONING=${MOSAIC_AGENT_REASONING:-}
|
||||
MOSAIC_AGENT_WORKDIR=${MOSAIC_AGENT_WORKDIR:-$HOME}
|
||||
MOSAIC_AGENT_COMMAND=${MOSAIC_AGENT_COMMAND:-}
|
||||
MOSAIC_HEARTBEAT_RUN_DIR=${MOSAIC_HEARTBEAT_RUN_DIR:-${MOSAIC_HOME:-$HOME/.config/mosaic}/fleet/run}
|
||||
MOSAIC_HEARTBEAT_INTERVAL=${MOSAIC_HEARTBEAT_INTERVAL:-15}
|
||||
|
||||
MODE=launch
|
||||
case "${1:-}" in
|
||||
--stop)
|
||||
MODE=stop
|
||||
AGENT_NAME=${2:-}
|
||||
if [ -z "$AGENT_NAME" ]; then
|
||||
echo "ERROR: agent name argument or MOSAIC_AGENT_NAME is required" >&2
|
||||
exit 64
|
||||
fi
|
||||
|
||||
case "$MOSAIC_AGENT_REASONING" in
|
||||
''|low|medium|high) ;;
|
||||
*)
|
||||
echo "ERROR: MOSAIC_AGENT_REASONING must be low, medium, or high" >&2
|
||||
exit 64
|
||||
;;
|
||||
--interaction)
|
||||
MODE=interaction
|
||||
AGENT_NAME=${2:-}
|
||||
;;
|
||||
*) AGENT_NAME=${1:-${MOSAIC_AGENT_NAME:-}} ;;
|
||||
esac
|
||||
MOSAIC_HOME=${MOSAIC_HOME:-$HOME/.config/mosaic}
|
||||
|
||||
fail() {
|
||||
echo "ERROR: $*" >&2
|
||||
exit 64
|
||||
}
|
||||
|
||||
hash_value() {
|
||||
printf '%s' "$1" | sha256sum | awk '{print $1}'
|
||||
}
|
||||
|
||||
fail_env() {
|
||||
local code="$1"
|
||||
local key="$2"
|
||||
local value="$3"
|
||||
echo "ERROR: agent environment rejected: code=${code} key=${key} sha256=$(hash_value "$value")" >&2
|
||||
exit 64
|
||||
}
|
||||
|
||||
safe_agent_name() {
|
||||
[[ "$1" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]*$ ]]
|
||||
}
|
||||
|
||||
safe_policy_name() {
|
||||
[[ "$1" =~ ^[a-z][a-z0-9-]*$ ]]
|
||||
}
|
||||
|
||||
safe_path() {
|
||||
[[ "$1" == /* ]] || return 1
|
||||
[[ "$1" != *".."* ]] || return 1
|
||||
[[ ! "$1" =~ [[:space:]\"\'\`\$\\\;\|\&\<\>\(\)\{\}] ]]
|
||||
}
|
||||
|
||||
assert_private_regular_file() {
|
||||
local file="$1"
|
||||
[ -f "$file" ] && [ ! -L "$file" ] || fail_env unsafe-file '(file)' "$file"
|
||||
local mode
|
||||
mode=$(stat -c '%a' -- "$file") || fail_env unsafe-file '(file)' "$file"
|
||||
(( (8#$mode & 8#077) == 0 )) || fail_env unsafe-permissions '(file)' "$file"
|
||||
}
|
||||
|
||||
assert_managed_directory() {
|
||||
local directory="$1"
|
||||
[ -d "$directory" ] && [ ! -L "$directory" ] || fail_env unsafe-directory '(directory)' "$directory"
|
||||
local mode
|
||||
mode=$(stat -c '%a' -- "$directory") || fail_env unsafe-directory '(directory)' "$directory"
|
||||
(( (8#$mode & 8#022) == 0 )) || fail_env unsafe-permissions '(directory)' "$directory"
|
||||
}
|
||||
|
||||
assert_private_directory() {
|
||||
local directory="$1"
|
||||
assert_managed_directory "$directory"
|
||||
local mode
|
||||
mode=$(stat -c '%a' -- "$directory") || fail_env unsafe-directory '(directory)' "$directory"
|
||||
(( (8#$mode & 8#077) == 0 )) || fail_env unsafe-permissions '(directory)' "$directory"
|
||||
}
|
||||
|
||||
[ -n "$AGENT_NAME" ] || fail "agent name argument or MOSAIC_AGENT_NAME is required"
|
||||
safe_agent_name "$AGENT_NAME" || fail_env unsafe-agent-name MOSAIC_AGENT_NAME "$AGENT_NAME"
|
||||
safe_path "$MOSAIC_HOME" || fail_env unsafe-path MOSAIC_HOME "$MOSAIC_HOME"
|
||||
|
||||
FLEET_DIR="$MOSAIC_HOME/fleet"
|
||||
AGENT_ENV_DIR="$FLEET_DIR/agents"
|
||||
assert_managed_directory "$MOSAIC_HOME"
|
||||
assert_managed_directory "$FLEET_DIR"
|
||||
assert_private_directory "$AGENT_ENV_DIR"
|
||||
|
||||
GENERATED_ENV="$AGENT_ENV_DIR/$AGENT_NAME.env.generated"
|
||||
LOCAL_ENV="$AGENT_ENV_DIR/$AGENT_NAME.env.local"
|
||||
|
||||
declare -A GENERATED_VALUES=()
|
||||
declare -A LOCAL_VALUES=()
|
||||
declare -A SEEN_KEYS=()
|
||||
|
||||
is_sensitive_key() {
|
||||
[[ "$1" =~ (API[_-]?KEY|AUTH|CREDENTIAL|PASSWORD|PRIVATE|SECRET|TOKEN) ]]
|
||||
}
|
||||
|
||||
is_generated_key() {
|
||||
case "$1" in
|
||||
MOSAIC_AGENT_NAME|MOSAIC_AGENT_CLASS|MOSAIC_AGENT_RUNTIME|MOSAIC_AGENT_MODEL|MOSAIC_AGENT_REASONING|MOSAIC_AGENT_TOOL_POLICY|MOSAIC_AGENT_WORKDIR|MOSAIC_TMUX_SOCKET) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
is_local_key() {
|
||||
case "$1" in
|
||||
MOSAIC_RUNTIME_BIN|MOSAIC_HEARTBEAT_RUN_DIR|MOSAIC_HEARTBEAT_INTERVAL|MOSAIC_CLAUDE_JSON|CLAUDE_CONFIG_DIR) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
validate_generated_value() {
|
||||
local key="$1"
|
||||
local value="$2"
|
||||
case "$key" in
|
||||
MOSAIC_AGENT_NAME) safe_agent_name "$value" || fail_env unsafe-agent-name "$key" "$value" ;;
|
||||
MOSAIC_AGENT_CLASS) safe_policy_name "$value" || fail_env unsafe-class "$key" "$value" ;;
|
||||
MOSAIC_AGENT_RUNTIME)
|
||||
case "$value" in claude|codex|opencode|pi) ;; *) fail_env unsupported-runtime "$key" "$value" ;; esac
|
||||
;;
|
||||
MOSAIC_AGENT_MODEL) [[ "$value" =~ ^[A-Za-z0-9._/:+-]*$ ]] || fail_env unsafe-model "$key" "$value" ;;
|
||||
MOSAIC_AGENT_REASONING)
|
||||
case "$value" in ''|low|medium|high) ;; *) fail_env unsupported-reasoning "$key" "$value" ;; esac
|
||||
;;
|
||||
MOSAIC_AGENT_TOOL_POLICY) [ -z "$value" ] || safe_policy_name "$value" || fail_env unsafe-tool-policy "$key" "$value" ;;
|
||||
MOSAIC_AGENT_WORKDIR) safe_path "$value" || fail_env unsafe-path "$key" "$value" ;;
|
||||
MOSAIC_TMUX_SOCKET) [[ "$value" =~ ^[A-Za-z0-9_.-]*$ ]] || fail_env unsafe-socket "$key" "$value" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
validate_local_value() {
|
||||
local key="$1"
|
||||
local value="$2"
|
||||
if [ "$key" = MOSAIC_HEARTBEAT_INTERVAL ]; then
|
||||
[[ "$value" =~ ^[1-9][0-9]*$ ]] || fail_env invalid-interval "$key" "$value"
|
||||
else
|
||||
safe_path "$value" || fail_env unsafe-path "$key" "$value"
|
||||
fi
|
||||
}
|
||||
|
||||
load_environment_file() {
|
||||
local file="$1"
|
||||
local kind="$2"
|
||||
[ -e "$file" ] || {
|
||||
[ "$kind" = generated ] && fail_env missing-file '(generated)' "$file"
|
||||
return 0
|
||||
}
|
||||
assert_private_regular_file "$file"
|
||||
SEEN_KEYS=()
|
||||
|
||||
local line key value
|
||||
while IFS= read -r line || [ -n "$line" ]; do
|
||||
[ -z "$line" ] && continue
|
||||
if [[ ! "$line" =~ ^([A-Z][A-Z0-9_]*)=(.*)$ ]]; then
|
||||
fail_env malformed-line '(malformed)' "$line"
|
||||
fi
|
||||
key=${BASH_REMATCH[1]}
|
||||
value=${BASH_REMATCH[2]}
|
||||
[ -z "${SEEN_KEYS[$key]+set}" ] || fail_env duplicate-key "$key" "$value"
|
||||
SEEN_KEYS[$key]=1
|
||||
is_sensitive_key "$key" && fail_env sensitive-key "$key" "$value"
|
||||
|
||||
if [ "$kind" = generated ]; then
|
||||
is_generated_key "$key" || fail_env unknown-key "$key" "$value"
|
||||
validate_generated_value "$key" "$value"
|
||||
GENERATED_VALUES[$key]=$value
|
||||
else
|
||||
is_generated_key "$key" && fail_env generated-key-shadow "$key" "$value"
|
||||
is_local_key "$key" || fail_env unknown-key "$key" "$value"
|
||||
validate_local_value "$key" "$value"
|
||||
LOCAL_VALUES[$key]=$value
|
||||
fi
|
||||
done < "$file"
|
||||
}
|
||||
|
||||
load_environment_file "$GENERATED_ENV" generated
|
||||
for required_key in \
|
||||
MOSAIC_AGENT_NAME MOSAIC_AGENT_CLASS MOSAIC_AGENT_RUNTIME MOSAIC_AGENT_MODEL \
|
||||
MOSAIC_AGENT_REASONING MOSAIC_AGENT_TOOL_POLICY MOSAIC_AGENT_WORKDIR MOSAIC_TMUX_SOCKET; do
|
||||
[ -n "${GENERATED_VALUES[$required_key]+set}" ] || fail_env missing-key "$required_key" ''
|
||||
done
|
||||
load_environment_file "$LOCAL_ENV" local
|
||||
|
||||
[ "${GENERATED_VALUES[MOSAIC_AGENT_NAME]}" = "$AGENT_NAME" ] || \
|
||||
fail_env agent-name-mismatch MOSAIC_AGENT_NAME "${GENERATED_VALUES[MOSAIC_AGENT_NAME]}"
|
||||
|
||||
MOSAIC_TMUX_SOCKET=${GENERATED_VALUES[MOSAIC_TMUX_SOCKET]}
|
||||
MOSAIC_AGENT_RUNTIME=${GENERATED_VALUES[MOSAIC_AGENT_RUNTIME]}
|
||||
MOSAIC_AGENT_MODEL=${GENERATED_VALUES[MOSAIC_AGENT_MODEL]}
|
||||
MOSAIC_AGENT_REASONING=${GENERATED_VALUES[MOSAIC_AGENT_REASONING]}
|
||||
MOSAIC_AGENT_WORKDIR=${GENERATED_VALUES[MOSAIC_AGENT_WORKDIR]}
|
||||
MOSAIC_AGENT_CLASS=${GENERATED_VALUES[MOSAIC_AGENT_CLASS]}
|
||||
MOSAIC_AGENT_TOOL_POLICY=${GENERATED_VALUES[MOSAIC_AGENT_TOOL_POLICY]}
|
||||
MOSAIC_RUNTIME_BIN=${LOCAL_VALUES[MOSAIC_RUNTIME_BIN]:-}
|
||||
MOSAIC_HEARTBEAT_RUN_DIR=${LOCAL_VALUES[MOSAIC_HEARTBEAT_RUN_DIR]:-$MOSAIC_HOME/fleet/run}
|
||||
MOSAIC_HEARTBEAT_INTERVAL=${LOCAL_VALUES[MOSAIC_HEARTBEAT_INTERVAL]:-15}
|
||||
MOSAIC_CLAUDE_JSON=${LOCAL_VALUES[MOSAIC_CLAUDE_JSON]:-}
|
||||
CLAUDE_CONFIG_DIR=${LOCAL_VALUES[CLAUDE_CONFIG_DIR]:-}
|
||||
|
||||
if ! command -v tmux >/dev/null 2>&1; then
|
||||
echo "ERROR: tmux is required" >&2
|
||||
exit 69
|
||||
fi
|
||||
|
||||
# tmux wrapper: pass -L only when a socket is configured. An absent/empty socket
|
||||
# means the default tmux socket (no -L), keeping spawn == observe == cheat-sheet.
|
||||
_tmux() {
|
||||
if [ -n "$MOSAIC_TMUX_SOCKET" ]; then
|
||||
tmux -L "$MOSAIC_TMUX_SOCKET" "$@"
|
||||
@@ -210,90 +42,140 @@ _tmux() {
|
||||
fi
|
||||
}
|
||||
|
||||
assert_owned_tmux_server() {
|
||||
local owner_file="$MOSAIC_HOME/fleet/run/holder-owner"
|
||||
[ -f "$owner_file" ] && [ ! -L "$owner_file" ] || fail "private tmux ownership identity is missing"
|
||||
local owner_mode
|
||||
owner_mode=$(stat -c '%a' -- "$owner_file") || fail "private tmux ownership identity is unreadable"
|
||||
(( (8#$owner_mode & 8#077) == 0 )) || fail "private tmux ownership identity has unsafe permissions"
|
||||
local owner
|
||||
owner=$(tr -d '\n' < "$owner_file")
|
||||
[[ "$owner" =~ ^[a-f0-9-]{36}$ ]] || fail "private tmux ownership identity is malformed"
|
||||
_tmux has-session -t '=_holder:0.0' 2>/dev/null || fail "owned tmux holder session is absent"
|
||||
local environment expected
|
||||
environment=$(_tmux show-environment -g 2>/dev/null) || fail "owned tmux global environment is unreadable"
|
||||
expected=$(printf '%s\n' \
|
||||
"HOME=$HOME" \
|
||||
'PATH=/usr/bin:/bin' \
|
||||
"PWD=$HOME" \
|
||||
"MOSAIC_FLEET_OWNER=$owner" \
|
||||
'MOSAIC_TMUX_HOLDER=_holder' \
|
||||
"MOSAIC_TMUX_SOCKET=$MOSAIC_TMUX_SOCKET" | sort)
|
||||
[ "$(printf '%s\n' "$environment" | sort)" = "$expected" ] || \
|
||||
fail "tmux server ownership or environment validation failed"
|
||||
}
|
||||
|
||||
# Validate exact server ownership before querying, cleaning, or creating any
|
||||
# managed session. An unmanaged or contaminated named socket is never repaired.
|
||||
assert_owned_tmux_server
|
||||
|
||||
if [ "$MODE" = interaction ]; then
|
||||
[ "$MOSAIC_AGENT_RUNTIME" = pi ] || fail "operator interaction service requires runtime pi"
|
||||
[ "$MOSAIC_AGENT_MODEL" = openai/gpt-5.6-sol ] || \
|
||||
fail "operator interaction service requires the pinned model"
|
||||
[ "$MOSAIC_AGENT_REASONING" = high ] || \
|
||||
fail "operator interaction service requires high reasoning"
|
||||
[ "$MOSAIC_AGENT_TOOL_POLICY" = operator-interaction ] || \
|
||||
fail "operator interaction service requires the operator-interaction tool policy"
|
||||
fi
|
||||
|
||||
if [ "$MODE" = stop ]; then
|
||||
_tmux kill-session -t "=${AGENT_NAME}" >/dev/null 2>&1 || true
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if _tmux has-session -t "=${AGENT_NAME}:0.0" 2>/dev/null; then
|
||||
echo "Mosaic agent session already running: $AGENT_NAME on socket ${MOSAIC_TMUX_SOCKET:-(default)}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Systemd passes HOME as %h, and the installed service fixes MOSAIC_HOME under
|
||||
# that home. Derive the pane home from the canonical path when available so an
|
||||
# inherited pane/session HOME cannot become runtime authority.
|
||||
PANE_HOME=$HOME
|
||||
case "$MOSAIC_HOME" in
|
||||
*/.config/mosaic) PANE_HOME=${MOSAIC_HOME%/.config/mosaic} ;;
|
||||
esac
|
||||
if [ -z "$MOSAIC_AGENT_COMMAND" ]; then
|
||||
# Map the roster's per-agent model_hint to `--model` so workers launch on the
|
||||
# configured model (e.g. pi on openai-codex/gpt-5.5:high). Omitted when unset.
|
||||
MOSAIC_AGENT_COMMAND="mosaic yolo $MOSAIC_AGENT_RUNTIME${MOSAIC_AGENT_MODEL:+ --model $MOSAIC_AGENT_MODEL}${MOSAIC_AGENT_REASONING:+ --thinking $MOSAIC_AGENT_REASONING}"
|
||||
fi
|
||||
|
||||
# ── Derive a runtime-bin PATH prefix ─────────────────────────────────────────
|
||||
# Precedence:
|
||||
# 1. $MOSAIC_RUNTIME_BIN (explicit override)
|
||||
# 2. $(npm config get prefix)/bin (if npm is on PATH)
|
||||
# 3. Fallbacks: $HOME/.npm-global/bin and $HOME/.local/bin
|
||||
#
|
||||
# Only directories that already exist are included. The prefix is baked into
|
||||
# the pane command regardless of what the LAUNCHER process's $PATH contains,
|
||||
# because the tmux pane inherits the tmux SERVER environment (not this script's
|
||||
# environment). A dir on the launcher's PATH may be absent from the server PATH,
|
||||
# so every existing candidate must always be included. Dedup within the
|
||||
# constructed prefix avoids listing the same dir twice.
|
||||
_build_runtime_bin_prefix() {
|
||||
local candidates=()
|
||||
if [ -n "$MOSAIC_RUNTIME_BIN" ]; then candidates+=("$MOSAIC_RUNTIME_BIN"); fi
|
||||
|
||||
if [ -n "${MOSAIC_RUNTIME_BIN:-}" ]; then
|
||||
candidates+=("$MOSAIC_RUNTIME_BIN")
|
||||
fi
|
||||
|
||||
if command -v npm >/dev/null 2>&1; then
|
||||
local npm_prefix
|
||||
npm_prefix=$(npm config get prefix 2>/dev/null) || true
|
||||
if [ -n "$npm_prefix" ]; then candidates+=("${npm_prefix}/bin"); fi
|
||||
if [ -n "$npm_prefix" ]; then
|
||||
candidates+=("${npm_prefix}/bin")
|
||||
fi
|
||||
fi
|
||||
candidates+=("$PANE_HOME/.npm-global/bin" "$PANE_HOME/.local/bin")
|
||||
|
||||
local prefix="" dir
|
||||
candidates+=("$HOME/.npm-global/bin")
|
||||
candidates+=("$HOME/.local/bin")
|
||||
|
||||
local prefix=""
|
||||
for dir in "${candidates[@]}"; do
|
||||
[ -d "$dir" ] || continue
|
||||
case ":${prefix}:" in *":${dir}:"*) ;; *) prefix="${prefix:+$prefix:}$dir" ;; esac
|
||||
if [ -z "$prefix" ]; then
|
||||
prefix="$dir"
|
||||
else
|
||||
case ":${prefix}:" in
|
||||
*":${dir}:"*) ;; # already in our prefix — skip
|
||||
*) prefix="${prefix}:${dir}" ;;
|
||||
esac
|
||||
fi
|
||||
done
|
||||
|
||||
printf '%s' "$prefix"
|
||||
}
|
||||
|
||||
MOSAIC_RUNTIME_BIN_PREFIX=$(_build_runtime_bin_prefix)
|
||||
PANE_PATH=${MOSAIC_RUNTIME_BIN_PREFIX:+${MOSAIC_RUNTIME_BIN_PREFIX}:}/usr/local/bin:/usr/bin:/bin
|
||||
|
||||
# ── Build the pane command ────────────────────────────────────────────────────
|
||||
# The pane command must:
|
||||
# - Export the augmented PATH so the runtime binary is found.
|
||||
# - exec the agent command so the runtime is the pane's foreground process
|
||||
# (makes `fleet ps` pane_current_command check reliable; no DRIFT false-positive).
|
||||
#
|
||||
# Quoting strategy: single-quote the inner shell snippet so that variable
|
||||
# references in MOSAIC_AGENT_COMMAND are NOT expanded here — they expand inside
|
||||
# the pane shell. However, MOSAIC_RUNTIME_BIN_PREFIX and PATH must be expanded
|
||||
# NOW (in this script) because the pane shell inherits the tmux server
|
||||
# environment, not this script's env.
|
||||
#
|
||||
# We build the snippet as a double-quoted here-string embedded in a printf call
|
||||
# to avoid nested quoting problems.
|
||||
#
|
||||
# MOSAIC_AGENT_NAME must also be exported INTO the pane: panes inherit the tmux
|
||||
# server environment (not this script's, and not the systemd unit's), so the
|
||||
# name would otherwise be empty in-pane and the runtime's native heartbeat
|
||||
# (which gates on MOSAIC_AGENT_NAME) would never fire. %q-quote it so it is a
|
||||
# safe single bash token regardless of the name's characters.
|
||||
AGENT_NAME_Q=$(printf '%q' "$AGENT_NAME")
|
||||
|
||||
# MOSAIC_AGENT_CLASS must ALSO be exported INTO the pane, for the same reason as
|
||||
# MOSAIC_AGENT_NAME above: the pane inherits the tmux SERVER environment (not this
|
||||
# script's env, and not the systemd unit's EnvironmentFile), so the per-agent class
|
||||
# written to agents/<name>.env would otherwise be invisible in-pane. The launcher
|
||||
# composes the persona contract from process.env.MOSAIC_AGENT_CLASS at launch
|
||||
# (compose-contract -> readPersonaContractBlock); without this export it sees an
|
||||
# undefined class and silently injects NO persona contract. %q-quote it so it is a
|
||||
# safe single bash token; an empty/unset class %q-quotes to '' and is a harmless
|
||||
# no-op downstream (readPersonaContractBlock returns '' for an empty class).
|
||||
AGENT_CLASS_Q=$(printf '%q' "${MOSAIC_AGENT_CLASS:-}")
|
||||
AGENT_TOOL_POLICY_Q=$(printf '%q' "${MOSAIC_AGENT_TOOL_POLICY:-}")
|
||||
|
||||
if [ -n "$MOSAIC_RUNTIME_BIN_PREFIX" ]; then
|
||||
PANE_SHELL_SNIPPET="export MOSAIC_AGENT_NAME=${AGENT_NAME_Q}; export MOSAIC_AGENT_CLASS=${AGENT_CLASS_Q}; export MOSAIC_AGENT_TOOL_POLICY=${AGENT_TOOL_POLICY_Q}; export PATH=\"${MOSAIC_RUNTIME_BIN_PREFIX}:\${PATH}\"; exec ${MOSAIC_AGENT_COMMAND}"
|
||||
else
|
||||
PANE_SHELL_SNIPPET="export MOSAIC_AGENT_NAME=${AGENT_NAME_Q}; export MOSAIC_AGENT_CLASS=${AGENT_CLASS_Q}; export MOSAIC_AGENT_TOOL_POLICY=${AGENT_TOOL_POLICY_Q}; exec ${MOSAIC_AGENT_COMMAND}"
|
||||
fi
|
||||
|
||||
mkdir -p "$MOSAIC_AGENT_WORKDIR"
|
||||
|
||||
# ── Pre-trust the workdir for the Claude runtime ─────────────────────────────
|
||||
# Claude Code shows a one-time "Is this a project you trust?" folder-trust gate
|
||||
# the first time it opens a directory. A fleet-launched agent has no human to
|
||||
# answer it, so the pane stalls forever at the prompt while its heartbeat keeps
|
||||
# reporting "healthy" (the pane process IS alive — it's just blocked).
|
||||
#
|
||||
# IMPORTANT: --dangerously-skip-permissions does NOT bypass this gate, and
|
||||
# neither does `trustedProjectDirectories` in settings.json (verified empirically
|
||||
# 2026-06-24). The ONLY thing the gate honors is the per-project record in
|
||||
# ~/.claude.json: projects["<dir>"].hasTrustDialogAccepted == true (exactly what
|
||||
# answering the prompt writes). So we pre-seed that record here.
|
||||
#
|
||||
# Idempotent, atomic, best-effort: any failure is non-fatal (the agent still
|
||||
# launches — worst case it stalls on the gate, i.e. the pre-fix status quo).
|
||||
# Only the claude runtime needs this; codex/pi have no such gate.
|
||||
_ensure_claude_workdir_trusted() {
|
||||
local workdir="$1"
|
||||
local resolved
|
||||
resolved=$(cd "$workdir" 2>/dev/null && pwd -P) || resolved="$workdir"
|
||||
# The path claude keys on is the resolved cwd it is launched in.
|
||||
local rp
|
||||
rp=$(cd "$workdir" 2>/dev/null && pwd -P) || rp="$workdir"
|
||||
# ~/.claude.json lives next to the claude config dir; honor CLAUDE_CONFIG_DIR.
|
||||
local claude_json="${MOSAIC_CLAUDE_JSON:-${CLAUDE_CONFIG_DIR:+$CLAUDE_CONFIG_DIR/.claude.json}}"
|
||||
claude_json="${claude_json:-$HOME/.claude.json}"
|
||||
command -v python3 >/dev/null 2>&1 || return 1
|
||||
MOSAIC_CJ="$claude_json" MOSAIC_TRUST_DIR="$resolved" python3 - <<'PY'
|
||||
|
||||
if ! command -v python3 >/dev/null 2>&1; then
|
||||
echo "WARNING: python3 not found; cannot pre-trust '$rp' for claude (agent may stall on the folder-trust gate)" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Serialize concurrent agent launches that share ~/.claude.json (flock if available).
|
||||
local lock="${claude_json}.mosaic-lock"
|
||||
_seed() {
|
||||
MOSAIC_CJ="$claude_json" MOSAIC_TRUST_DIR="$rp" python3 - <<'PY'
|
||||
import json, os, sys, tempfile
|
||||
cj = os.environ["MOSAIC_CJ"]
|
||||
d = os.environ["MOSAIC_TRUST_DIR"]
|
||||
@@ -302,19 +184,22 @@ try:
|
||||
if not isinstance(data, dict):
|
||||
data = {}
|
||||
except Exception:
|
||||
# Never corrupt an unreadable/partial file — bail without writing.
|
||||
sys.exit(2)
|
||||
projects = data.setdefault("projects", {})
|
||||
entry = projects.get(d)
|
||||
if not isinstance(entry, dict):
|
||||
entry = {}
|
||||
projects[d] = entry
|
||||
if entry.get("hasTrustDialogAccepted") is True:
|
||||
sys.exit(0) # already trusted — nothing to do
|
||||
entry["hasTrustDialogAccepted"] = True
|
||||
tmp_dir = os.path.dirname(cj) or "."
|
||||
fd, tmp = tempfile.mkstemp(dir=tmp_dir, prefix=".claude.json.mosaic.")
|
||||
try:
|
||||
with os.fdopen(fd, "w") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
os.replace(tmp, cj)
|
||||
os.replace(tmp, cj) # atomic
|
||||
except Exception:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
@@ -322,56 +207,56 @@ except Exception:
|
||||
pass
|
||||
sys.exit(3)
|
||||
PY
|
||||
}
|
||||
if command -v flock >/dev/null 2>&1; then
|
||||
( flock 9; _seed ) 9>"$lock" 2>/dev/null || _seed
|
||||
else
|
||||
_seed
|
||||
fi
|
||||
}
|
||||
|
||||
if [ "$MOSAIC_AGENT_RUNTIME" = claude ]; then
|
||||
_ensure_claude_workdir_trusted "$MOSAIC_AGENT_WORKDIR" || \
|
||||
echo "WARNING: could not pre-trust workdir for claude agent $AGENT_NAME" >&2
|
||||
fi
|
||||
case "$MOSAIC_AGENT_RUNTIME" in
|
||||
claude)
|
||||
_ensure_claude_workdir_trusted "$MOSAIC_AGENT_WORKDIR" \
|
||||
|| echo "WARNING: could not pre-trust workdir for claude agent $AGENT_NAME" >&2
|
||||
;;
|
||||
esac
|
||||
|
||||
LAUNCH_COMMAND=(mosaic yolo "$MOSAIC_AGENT_RUNTIME")
|
||||
if [ -n "$MOSAIC_AGENT_MODEL" ]; then LAUNCH_COMMAND+=(--model "$MOSAIC_AGENT_MODEL"); fi
|
||||
if [ -n "$MOSAIC_AGENT_REASONING" ]; then LAUNCH_COMMAND+=(--thinking "$MOSAIC_AGENT_REASONING"); fi
|
||||
|
||||
# The tmux holder owns a named server. Explicitly clear the pane environment
|
||||
# so server/session variables cannot cross the launch boundary; retain only
|
||||
# trusted bootstrap, generated, and approved local data as argv assignments.
|
||||
LAUNCH_ENV=(
|
||||
/usr/bin/env
|
||||
-i
|
||||
"HOME=$PANE_HOME"
|
||||
"PATH=$PANE_PATH"
|
||||
"MOSAIC_HOME=$MOSAIC_HOME"
|
||||
"MOSAIC_AGENT_NAME=$AGENT_NAME"
|
||||
"MOSAIC_AGENT_CLASS=$MOSAIC_AGENT_CLASS"
|
||||
"MOSAIC_AGENT_RUNTIME=$MOSAIC_AGENT_RUNTIME"
|
||||
"MOSAIC_AGENT_MODEL=$MOSAIC_AGENT_MODEL"
|
||||
"MOSAIC_AGENT_REASONING=$MOSAIC_AGENT_REASONING"
|
||||
"MOSAIC_AGENT_TOOL_POLICY=$MOSAIC_AGENT_TOOL_POLICY"
|
||||
"MOSAIC_AGENT_WORKDIR=$MOSAIC_AGENT_WORKDIR"
|
||||
"MOSAIC_TMUX_SOCKET=$MOSAIC_TMUX_SOCKET"
|
||||
"MOSAIC_HEARTBEAT_RUN_DIR=$MOSAIC_HEARTBEAT_RUN_DIR"
|
||||
)
|
||||
|
||||
mkdir -p "$MOSAIC_AGENT_WORKDIR"
|
||||
# ── Launch the tmux session (no exec — we continue to wire the heartbeat) ────
|
||||
_tmux new-session -d -s "$AGENT_NAME" -c "$MOSAIC_AGENT_WORKDIR" \
|
||||
"${LAUNCH_ENV[@]}" "${LAUNCH_COMMAND[@]}"
|
||||
bash -c "$PANE_SHELL_SNIPPET"
|
||||
|
||||
# ── Resolve the pane PID (retry briefly to let the session initialise) ────────
|
||||
PANE_PID=""
|
||||
for _retry in 1 2 3 4 5; do
|
||||
PANE_PID=$(_tmux list-panes -t "=${AGENT_NAME}:0.0" -F '#{pane_pid}' 2>/dev/null || true)
|
||||
PANE_PID=$(_tmux list-panes \
|
||||
-t "=${AGENT_NAME}:0.0" -F '#{pane_pid}' 2>/dev/null || true)
|
||||
[ -n "$PANE_PID" ] && break
|
||||
sleep 0.2
|
||||
done
|
||||
|
||||
# ── Spawn the heartbeat sidecar (detached, best-effort) ──────────────────────
|
||||
# The sidecar writes ~/.config/mosaic/fleet/run/<AGENT>.hb atomically while the
|
||||
# pane process is alive, then exits so the file goes stale (fleet ps shows stale
|
||||
# then PANE=dead). It is runtime-agnostic: it only cares about the pane PID.
|
||||
_start_heartbeat_sidecar() {
|
||||
local agent="$1" pane_pid="$2" run_dir="$3" interval="$4"
|
||||
local agent="$1"
|
||||
local pane_pid="$2"
|
||||
local run_dir="$3"
|
||||
local interval="$4"
|
||||
local hb_file="${run_dir}/${agent}.hb"
|
||||
|
||||
mkdir -p "$run_dir"
|
||||
|
||||
# Write the sidecar as a self-contained bash one-liner so it carries no
|
||||
# references to any variables from this script's environment.
|
||||
local sidecar_script
|
||||
sidecar_script=$(printf \
|
||||
'hb=%q; pid=%q; iv=%q; native="$hb.native"; mkdir -p "$(dirname "$hb")"; while kill -0 "$pid" 2>/dev/null; do now=$(date +%%s); marker=$(stat -c %%Y -- "$native" 2>/dev/null || true); if [ -z "$marker" ] || [ -L "$native" ] || (( now - marker > iv * 2 + 1 )); then tmp="$hb.tmp.$$"; printf "ts=%%s\npid=%%s\nstatus=ok\n" "$(date +%%Y-%%m-%%dT%%H:%%M:%%S%%z)" "$pid" > "$tmp" && mv "$tmp" "$hb"; fi; sleep "$iv"; done' \
|
||||
'hb=%q; pid=%q; iv=%q; mkdir -p "$(dirname "$hb")"; while kill -0 "$pid" 2>/dev/null; do nat="$hb.native"; if [ -f "$nat" ] && [ "$(( $(date +%%s) - $(stat -c %%Y "$nat" 2>/dev/null || echo 0) ))" -lt "$(( iv * 2 ))" ]; then sleep "$iv"; continue; fi; tmp="$hb.tmp.$$"; printf "ts=%%s\npid=%%s\nstatus=ok\n" "$(date +%%Y-%%m-%%dT%%H:%%M:%%S%%z)" "$pid" > "$tmp" && mv "$tmp" "$hb"; sleep "$iv"; done' \
|
||||
"$hb_file" "$pane_pid" "$interval")
|
||||
|
||||
# setsid + disown ensures the sidecar survives this script exiting.
|
||||
# stderr/stdout go to /dev/null; failures are non-fatal.
|
||||
if command -v setsid >/dev/null 2>&1; then
|
||||
setsid bash -c "$sidecar_script" </dev/null >/dev/null 2>&1 &
|
||||
else
|
||||
@@ -381,6 +266,7 @@ _start_heartbeat_sidecar() {
|
||||
}
|
||||
|
||||
if [ -n "$PANE_PID" ]; then
|
||||
# Guard: do not let sidecar startup failures abort the launcher (set -e).
|
||||
_start_heartbeat_sidecar "$AGENT_NAME" "$PANE_PID" \
|
||||
"$MOSAIC_HEARTBEAT_RUN_DIR" "$MOSAIC_HEARTBEAT_INTERVAL" || \
|
||||
echo "WARNING: heartbeat sidecar could not be started for $AGENT_NAME" >&2
|
||||
|
||||
@@ -9,7 +9,11 @@ fail() {
|
||||
}
|
||||
|
||||
[ -n "$AGENT_NAME" ] || fail "agent name argument is required"
|
||||
[[ "$AGENT_NAME" =~ ^[A-Za-z0-9_.-]+$ ]] || fail "agent name contains unsupported characters"
|
||||
[ "${MOSAIC_AGENT_NAME:-}" = "$AGENT_NAME" ] || fail "configured agent name must exactly match the service instance"
|
||||
[ "${MOSAIC_AGENT_RUNTIME:-}" = "pi" ] || fail "operator interaction service requires runtime pi"
|
||||
[ "${MOSAIC_AGENT_MODEL:-}" = "openai/gpt-5.6-sol" ] || fail "operator interaction service requires the pinned model"
|
||||
[ "${MOSAIC_AGENT_REASONING:-}" = "high" ] || fail "operator interaction service requires high reasoning"
|
||||
[ "${MOSAIC_AGENT_TOOL_POLICY:-}" = "operator-interaction" ] || fail "operator interaction service requires the operator-interaction tool policy"
|
||||
|
||||
# The shared launcher strictly validates the generated/local data boundary
|
||||
# before it applies this interaction service's pinned profile checks.
|
||||
exec "$(cd -- "$(dirname -- "$0")" && pwd)/start-agent-session.sh" --interaction "$AGENT_NAME"
|
||||
exec "$(cd -- "$(dirname -- "$0")" && pwd)/start-agent-session.sh" "$AGENT_NAME"
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# A holder may create only the configured named socket. Existing servers are
|
||||
# accepted only when their private install-derived ownership identity, exact
|
||||
# holder session, and complete approved global environment all match.
|
||||
|
||||
MOSAIC_HOME=${MOSAIC_HOME:-$HOME/.config/mosaic}
|
||||
MOSAIC_TMUX_SOCKET=${MOSAIC_TMUX_SOCKET:-mosaic-fleet}
|
||||
MOSAIC_TMUX_HOLDER=${MOSAIC_TMUX_HOLDER:-_holder}
|
||||
OWNER_FILE="$MOSAIC_HOME/fleet/run/holder-owner"
|
||||
TMUX_BIN=/usr/bin/tmux
|
||||
|
||||
fail() {
|
||||
echo "ERROR: refusing unmanaged Mosaic tmux server on socket ${MOSAIC_TMUX_SOCKET}: $1" >&2
|
||||
exit 64
|
||||
}
|
||||
|
||||
[ -x "$TMUX_BIN" ] || fail "tmux binary is unavailable"
|
||||
[ -f "$OWNER_FILE" ] && [ ! -L "$OWNER_FILE" ] || fail "private ownership identity is missing"
|
||||
owner_mode=$(stat -c '%a' -- "$OWNER_FILE") || fail "private ownership identity is unreadable"
|
||||
(( (8#$owner_mode & 8#077) == 0 )) || fail "private ownership identity has unsafe permissions"
|
||||
MOSAIC_FLEET_OWNER=$(tr -d '\n' < "$OWNER_FILE")
|
||||
[[ "$MOSAIC_FLEET_OWNER" =~ ^[a-f0-9-]{36}$ ]] || fail "private ownership identity is malformed"
|
||||
|
||||
_tmux() {
|
||||
"$TMUX_BIN" -L "$MOSAIC_TMUX_SOCKET" "$@"
|
||||
}
|
||||
|
||||
server_running() {
|
||||
_tmux list-sessions >/dev/null 2>&1
|
||||
}
|
||||
|
||||
assert_owned_server() {
|
||||
_tmux has-session -t "=${MOSAIC_TMUX_HOLDER}:0.0" 2>/dev/null || fail "exact holder session is absent"
|
||||
local environment
|
||||
environment=$(_tmux show-environment -g 2>/dev/null) || fail "global environment is unreadable"
|
||||
local expected
|
||||
expected=$(printf '%s\n' \
|
||||
"HOME=$HOME" \
|
||||
'PATH=/usr/bin:/bin' \
|
||||
"PWD=$HOME" \
|
||||
"MOSAIC_FLEET_OWNER=$MOSAIC_FLEET_OWNER" \
|
||||
"MOSAIC_TMUX_HOLDER=$MOSAIC_TMUX_HOLDER" \
|
||||
"MOSAIC_TMUX_SOCKET=$MOSAIC_TMUX_SOCKET" | sort)
|
||||
[ "$(printf '%s\n' "$environment" | sort)" = "$expected" ] || \
|
||||
fail "global environment does not match the owned-server contract"
|
||||
}
|
||||
|
||||
if server_running; then
|
||||
assert_owned_server
|
||||
else
|
||||
cd "$HOME" || fail "trusted home is unavailable"
|
||||
# Start the tmux server itself under the approved environment. The holder pane
|
||||
# receives the same closed environment rather than arbitrary server globals.
|
||||
/usr/bin/env -i \
|
||||
"HOME=$HOME" \
|
||||
PATH=/usr/bin:/bin \
|
||||
"MOSAIC_FLEET_OWNER=$MOSAIC_FLEET_OWNER" \
|
||||
"MOSAIC_TMUX_HOLDER=$MOSAIC_TMUX_HOLDER" \
|
||||
"MOSAIC_TMUX_SOCKET=$MOSAIC_TMUX_SOCKET" \
|
||||
"$TMUX_BIN" -L "$MOSAIC_TMUX_SOCKET" new-session -d -s "$MOSAIC_TMUX_HOLDER" \
|
||||
/usr/bin/env -i "HOME=$HOME" PATH=/usr/bin:/bin /bin/sh -c 'while true; do sleep 3600; done'
|
||||
fi
|
||||
@@ -3,410 +3,373 @@ set -euo pipefail
|
||||
|
||||
SCRIPT_DIR=$(cd -- "$(dirname -- "$0")" && pwd)
|
||||
START="$SCRIPT_DIR/start-agent-session.sh"
|
||||
INTERACTION_START="$SCRIPT_DIR/start-interaction-service.sh"
|
||||
ROOT=$(mktemp -d)
|
||||
FAKE_BIN=$(mktemp -d)
|
||||
TMUX_CALLS=$(mktemp)
|
||||
trap 'rm -rf "$ROOT" "$FAKE_BIN" "$TMUX_CALLS"' EXIT
|
||||
SOCKET="mosaic-agent-test-$RANDOM-$$"
|
||||
AGENT="agent-$RANDOM"
|
||||
WORKDIR=$(mktemp -d)
|
||||
|
||||
# Keep a single cleanup trap that accumulates resources.
|
||||
CLEANUP_DIRS=("$WORKDIR")
|
||||
CLEANUP_SOCKETS=("$SOCKET")
|
||||
trap '_cleanup' EXIT
|
||||
_cleanup() {
|
||||
for s in "${CLEANUP_SOCKETS[@]:-}"; do
|
||||
tmux -L "$s" kill-server >/dev/null 2>&1 || true
|
||||
done
|
||||
for d in "${CLEANUP_DIRS[@]:-}"; do
|
||||
rm -rf "$d"
|
||||
done
|
||||
}
|
||||
|
||||
fail() {
|
||||
echo "FAIL: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
cat > "$FAKE_BIN/tmux" <<'SHIM'
|
||||
# ── Test 1: basic session creation with workdir check ─────────────────────────
|
||||
MOSAIC_TMUX_SOCKET="$SOCKET" \
|
||||
MOSAIC_AGENT_WORKDIR="$WORKDIR" \
|
||||
MOSAIC_AGENT_COMMAND='bash --noprofile --norc -i' \
|
||||
"$START" "$AGENT"
|
||||
|
||||
tmux -L "$SOCKET" has-session -t "=$AGENT:0.0" || fail "agent session was not created"
|
||||
# Retry: pane_current_path briefly reflects the tmux server's cwd until the pane
|
||||
# process establishes its own cwd (the -c start dir). Poll until it settles.
|
||||
actual_dir=""
|
||||
for _ in $(seq 1 30); do
|
||||
actual_dir=$(tmux -L "$SOCKET" display-message -p -t "=$AGENT:0.0" '#{pane_current_path}')
|
||||
[ "$actual_dir" = "$WORKDIR" ] && break
|
||||
sleep 0.1
|
||||
done
|
||||
[ "$actual_dir" = "$WORKDIR" ] || fail "agent workdir mismatch: $actual_dir (expected $WORKDIR)"
|
||||
|
||||
# ── Test 2: idempotency (duplicate start prints 'already running') ─────────────
|
||||
MOSAIC_TMUX_SOCKET="$SOCKET" \
|
||||
MOSAIC_AGENT_WORKDIR="$WORKDIR" \
|
||||
MOSAIC_AGENT_COMMAND='bash --noprofile --norc -i' \
|
||||
"$START" "$AGENT" >/tmp/mosaic-start-agent-idempotent.out
|
||||
|
||||
grep -qF 'already running' /tmp/mosaic-start-agent-idempotent.out || fail "duplicate start was not idempotent"
|
||||
|
||||
# ── Test 3: runtime-bin PATH prefix is baked into the pane command ────────────
|
||||
#
|
||||
# We capture the command the script would hand to tmux by injecting a fake
|
||||
# 'tmux' shim into PATH. The shim:
|
||||
# - Intercepts 'new-session' calls and records its arguments to a file.
|
||||
# - For 'has-session' calls, exits 1 (session does not exist) so the script
|
||||
# proceeds to launch instead of printing "already running".
|
||||
# - For 'list-panes' calls, returns empty so PANE_PID stays unset and the
|
||||
# heartbeat sidecar is NOT spawned (heartbeat is not the focus of this test;
|
||||
# test 6 and 7 cover that path). This prevents any real-filesystem side
|
||||
# effects or leaked background processes.
|
||||
# - For all other subcommands, exits 0.
|
||||
#
|
||||
# Assertions:
|
||||
# a) 'export PATH=' with the synthetic MOSAIC_RUNTIME_BIN prefix appears.
|
||||
# b) 'exec' appears so the runtime replaces the wrapper shell.
|
||||
# c) MOSAIC_AGENT_COMMAND with flags is forwarded intact.
|
||||
|
||||
FAKE_BIN=$(mktemp -d)
|
||||
FAKE_RUNTIME_BIN=$(mktemp -d)
|
||||
TMUX_ARGS_FILE=$(mktemp)
|
||||
HB_RUN_DIR3=$(mktemp -d)
|
||||
CLEANUP_DIRS+=("$FAKE_BIN" "$FAKE_RUNTIME_BIN" "$HB_RUN_DIR3")
|
||||
|
||||
# Write the fake tmux shim (uses only positional args, no sourced vars).
|
||||
cat > "$FAKE_BIN/tmux" <<SHIM
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
printf '%s\0' "$@" >> "${MOSAIC_TEST_TMUX_CALLS:?}"
|
||||
args=("$@")
|
||||
index=0
|
||||
if [ "${args[0]:-}" = -L ]; then index=2; fi
|
||||
case "${args[$index]:-}" in
|
||||
has-session)
|
||||
for argument in "${args[@]}"; do
|
||||
[ "$argument" = '=_holder:0.0' ] && exit 0
|
||||
done
|
||||
exit 1
|
||||
;;
|
||||
show-environment)
|
||||
printf '%s\n' \
|
||||
"HOME=${MOSAIC_TEST_HOME:?}" \
|
||||
'PATH=/usr/bin:/bin' \
|
||||
"PWD=${MOSAIC_TEST_HOME:?}" \
|
||||
"MOSAIC_FLEET_OWNER=${MOSAIC_TEST_FLEET_OWNER:?}" \
|
||||
'MOSAIC_TMUX_HOLDER=_holder' \
|
||||
'MOSAIC_TMUX_SOCKET=mosaic-test'
|
||||
exit 0
|
||||
;;
|
||||
list-panes) printf '%s\n' "${MOSAIC_TEST_PANE_PID:-}"; exit 0 ;;
|
||||
new-session)
|
||||
if [ "${MOSAIC_TEST_EXECUTE_PANE:-}" = 1 ]; then
|
||||
for ((index = 0; index < ${#args[@]}; index++)); do
|
||||
if [ "${args[$index]}" = /usr/bin/env ]; then
|
||||
"${args[@]:$index}"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
exit 0
|
||||
;;
|
||||
*) exit 0 ;;
|
||||
esac
|
||||
# Fake tmux: record new-session args; report has-session as missing.
|
||||
subcmd="\$3" # argv: tmux -L <socket> <subcmd> ...
|
||||
if [ "\$subcmd" = "has-session" ]; then
|
||||
exit 1 # session not found → script will attempt new-session
|
||||
fi
|
||||
if [ "\$subcmd" = "new-session" ]; then
|
||||
printf '%s\n' "\$@" > "$TMUX_ARGS_FILE"
|
||||
exit 0
|
||||
fi
|
||||
if [ "\$subcmd" = "list-panes" ]; then
|
||||
# Return empty: no sidecar spawned (heartbeat is not the focus of this test).
|
||||
echo ""
|
||||
exit 0
|
||||
fi
|
||||
exit 0
|
||||
SHIM
|
||||
chmod +x "$FAKE_BIN/tmux"
|
||||
|
||||
cat > "$FAKE_BIN/mosaic" <<'SHIM'
|
||||
SOCKET3="mosaic-agent-test3-$RANDOM-$$"
|
||||
AGENT3="agent3-$RANDOM"
|
||||
WORKDIR3=$(mktemp -d)
|
||||
CLEANUP_DIRS+=("$WORKDIR3")
|
||||
|
||||
PATH="$FAKE_BIN:$PATH" \
|
||||
MOSAIC_TMUX_SOCKET="$SOCKET3" \
|
||||
MOSAIC_AGENT_WORKDIR="$WORKDIR3" \
|
||||
MOSAIC_AGENT_RUNTIME="pi" \
|
||||
MOSAIC_AGENT_CLASS="code" \
|
||||
MOSAIC_AGENT_TOOL_POLICY="operator-interaction" \
|
||||
MOSAIC_RUNTIME_BIN="$FAKE_RUNTIME_BIN" \
|
||||
MOSAIC_AGENT_COMMAND="mosaic yolo pi --model openai-codex/gpt-5.5:high" \
|
||||
MOSAIC_HEARTBEAT_RUN_DIR="$HB_RUN_DIR3" \
|
||||
"$START" "$AGENT3"
|
||||
|
||||
all_args=$(cat "$TMUX_ARGS_FILE" 2>/dev/null || true)
|
||||
rm -f "$TMUX_ARGS_FILE"
|
||||
|
||||
echo "--- captured tmux new-session args ---"
|
||||
echo "$all_args"
|
||||
echo "--- end args ---"
|
||||
|
||||
# a) PATH prefix containing FAKE_RUNTIME_BIN must appear.
|
||||
echo "$all_args" | grep -qF "export PATH=" || fail "pane command does not export PATH"
|
||||
echo "$all_args" | grep -qF "$FAKE_RUNTIME_BIN" || fail "pane command does not include MOSAIC_RUNTIME_BIN in PATH prefix"
|
||||
|
||||
# b) exec must appear so the runtime replaces the wrapper shell.
|
||||
echo "$all_args" | grep -qF "exec " || fail "pane command does not use exec"
|
||||
|
||||
# c) Full MOSAIC_AGENT_COMMAND (with flags) must be forwarded.
|
||||
echo "$all_args" | grep -qF "mosaic yolo pi --model openai-codex/gpt-5.5:high" || \
|
||||
fail "pane command does not forward MOSAIC_AGENT_COMMAND with flags intact"
|
||||
|
||||
# d) MOSAIC_AGENT_NAME and the per-agent MOSAIC_AGENT_CLASS must BOTH be exported
|
||||
# INTO the pane. The pane inherits the tmux SERVER environment (not this
|
||||
# script's env, nor the systemd unit's EnvironmentFile), so any per-agent var
|
||||
# the launcher needs in-pane must be re-exported in the snippet. CLASS is
|
||||
# load-bearing: the launcher composes the persona contract from
|
||||
# process.env.MOSAIC_AGENT_CLASS, so a missing export silently drops the
|
||||
# persona (regression guard for the A3a pane-propagation gap).
|
||||
echo "$all_args" | grep -qF "export MOSAIC_AGENT_NAME=" || \
|
||||
fail "pane command does not export MOSAIC_AGENT_NAME into the pane"
|
||||
echo "$all_args" | grep -qF "export MOSAIC_AGENT_CLASS=code" || \
|
||||
fail "pane command does not export MOSAIC_AGENT_CLASS into the pane (persona would silently drop)"
|
||||
echo "$all_args" | grep -qF "export MOSAIC_AGENT_TOOL_POLICY=operator-interaction" || \
|
||||
fail "pane command does not export MOSAIC_AGENT_TOOL_POLICY into the pane"
|
||||
|
||||
# ── Test 4: when no extra runtime-bin dirs exist, exec still appears ───────────
|
||||
TMUX_ARGS_FILE2=$(mktemp)
|
||||
FAKE_BIN2=$(mktemp -d)
|
||||
HB_RUN_DIR4=$(mktemp -d)
|
||||
CLEANUP_DIRS+=("$FAKE_BIN2" "$HB_RUN_DIR4")
|
||||
|
||||
cat > "$FAKE_BIN2/tmux" <<SHIM2
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
env -0 > "${MOSAIC_HOME:?}/fleet/pane-environment"
|
||||
SHIM
|
||||
chmod +x "$FAKE_BIN/mosaic"
|
||||
|
||||
write_generated() {
|
||||
local home="$1"
|
||||
local agent="$2"
|
||||
mkdir -p "$home/fleet/agents" "$home/fleet/run"
|
||||
chmod 700 "$home" "$home/fleet" "$home/fleet/agents" "$home/fleet/run"
|
||||
printf '123e4567-e89b-12d3-a456-426614174000\n' > "$home/fleet/run/holder-owner"
|
||||
chmod 600 "$home/fleet/run/holder-owner"
|
||||
cat > "$home/fleet/agents/$agent.env.generated" <<EOF
|
||||
MOSAIC_AGENT_NAME=$agent
|
||||
MOSAIC_AGENT_CLASS=code
|
||||
MOSAIC_AGENT_RUNTIME=pi
|
||||
MOSAIC_AGENT_MODEL=openai-codex/gpt-5.6-sol
|
||||
MOSAIC_AGENT_REASONING=high
|
||||
MOSAIC_AGENT_TOOL_POLICY=code
|
||||
MOSAIC_AGENT_WORKDIR=$home/work
|
||||
MOSAIC_TMUX_SOCKET=mosaic-test
|
||||
EOF
|
||||
chmod 600 "$home/fleet/agents/$agent.env.generated"
|
||||
mkdir -p "$home/work"
|
||||
}
|
||||
|
||||
run_start() {
|
||||
local home="$1"
|
||||
local agent="$2"
|
||||
HOME="$home" PATH="$FAKE_BIN:$PATH" MOSAIC_TEST_TMUX_CALLS="$TMUX_CALLS" \
|
||||
MOSAIC_TEST_PANE_PID="${MOSAIC_TEST_PANE_PID:-}" \
|
||||
MOSAIC_TEST_HOME="$home" \
|
||||
MOSAIC_TEST_FLEET_OWNER=123e4567-e89b-12d3-a456-426614174000 \
|
||||
MOSAIC_HOME="$home" "$START" "$agent"
|
||||
}
|
||||
|
||||
# Valid generated data launches only the fixed runtime argument array. It never
|
||||
# reads an agent-command string or constructs a bash -c pane payload.
|
||||
HOME_VALID="$ROOT/valid"
|
||||
AGENT_VALID="coder0"
|
||||
write_generated "$HOME_VALID" "$AGENT_VALID"
|
||||
run_start "$HOME_VALID" "$AGENT_VALID"
|
||||
valid_args=$(tr '\0' '\n' < "$TMUX_CALLS")
|
||||
echo "$valid_args" | grep -qF new-session || fail "valid generated projection did not reach tmux"
|
||||
echo "$valid_args" | grep -qF 'mosaic' || fail "fixed mosaic launcher command missing"
|
||||
echo "$valid_args" | grep -qF 'yolo' || fail "fixed yolo launcher command missing"
|
||||
echo "$valid_args" | grep -qF 'pi' || fail "roster runtime missing"
|
||||
if echo "$valid_args" | grep -qF 'bash -c'; then
|
||||
fail "launcher constructed a shell command payload"
|
||||
subcmd="\$3"
|
||||
if [ "\$subcmd" = "has-session" ]; then exit 1; fi
|
||||
if [ "\$subcmd" = "new-session" ]; then
|
||||
printf '%s\n' "\$@" > "$TMUX_ARGS_FILE2"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# The pane must start through an absolute clean-environment boundary. Its
|
||||
# runtime command remains an argv vector, but no holder/session environment
|
||||
# control variable can pass through the pane command.
|
||||
echo "$valid_args" | grep -qxF '/usr/bin/env' || fail "pane does not use absolute env"
|
||||
echo "$valid_args" | grep -qxF -- '-i' || fail "pane environment is not cleared"
|
||||
|
||||
# The generated-file parent is a security boundary too: even a private regular
|
||||
# file is untrusted if its parent can be replaced or written by another user.
|
||||
# Validation must happen before fake tmux receives even a has-session call.
|
||||
: > "$TMUX_CALLS"
|
||||
HOME_UNSAFE_PARENT="$ROOT/unsafe-parent"
|
||||
write_generated "$HOME_UNSAFE_PARENT" "coder-parent"
|
||||
chmod 777 "$HOME_UNSAFE_PARENT/fleet/agents"
|
||||
if output=$(run_start "$HOME_UNSAFE_PARENT" coder-parent 2>&1); then
|
||||
fail "generated file under a world-writable parent was accepted"
|
||||
if [ "\$subcmd" = "list-panes" ]; then
|
||||
# Return empty: no sidecar spawned (heartbeat is not the focus of this test).
|
||||
echo ""
|
||||
exit 0
|
||||
fi
|
||||
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before unsafe parent rejection"
|
||||
echo "$output" | grep -qF 'code=unsafe-permissions' || fail "unsafe parent diagnostic missing"
|
||||
exit 0
|
||||
SHIM2
|
||||
chmod +x "$FAKE_BIN2/tmux"
|
||||
|
||||
: > "$TMUX_CALLS"
|
||||
HOME_SYMLINK_PARENT="$ROOT/symlink-parent"
|
||||
write_generated "$HOME_SYMLINK_PARENT" "coder-symlink-parent"
|
||||
mv "$HOME_SYMLINK_PARENT/fleet/agents" "$HOME_SYMLINK_PARENT/private-agents"
|
||||
ln -s "$HOME_SYMLINK_PARENT/private-agents" "$HOME_SYMLINK_PARENT/fleet/agents"
|
||||
if output=$(run_start "$HOME_SYMLINK_PARENT" coder-symlink-parent 2>&1); then
|
||||
fail "generated file under a symlinked parent was accepted"
|
||||
SOCKET4="mosaic-agent-test4-$RANDOM-$$"
|
||||
AGENT4="agent4-$RANDOM"
|
||||
WORKDIR4=$(mktemp -d)
|
||||
CLEANUP_DIRS+=("$WORKDIR4")
|
||||
|
||||
# MOSAIC_RUNTIME_BIN points to a non-existent dir so prefix will be empty;
|
||||
# .npm-global/bin and .local/bin may or may not exist but we just want exec.
|
||||
PATH="$FAKE_BIN2:$PATH" \
|
||||
MOSAIC_TMUX_SOCKET="$SOCKET4" \
|
||||
MOSAIC_AGENT_WORKDIR="$WORKDIR4" \
|
||||
MOSAIC_AGENT_RUNTIME="pi" \
|
||||
MOSAIC_RUNTIME_BIN="/nonexistent-dir-$$" \
|
||||
MOSAIC_AGENT_COMMAND="mosaic yolo pi" \
|
||||
MOSAIC_HEARTBEAT_RUN_DIR="$HB_RUN_DIR4" \
|
||||
"$START" "$AGENT4"
|
||||
|
||||
all_args4=$(cat "$TMUX_ARGS_FILE2" 2>/dev/null || true)
|
||||
rm -f "$TMUX_ARGS_FILE2"
|
||||
rm -rf "$WORKDIR4"
|
||||
|
||||
echo "$all_args4" | grep -qF "exec " || fail "pane command (no prefix dirs) does not use exec"
|
||||
echo "$all_args4" | grep -qF "mosaic yolo pi" || fail "pane command does not include agent command when no prefix"
|
||||
|
||||
# ── Test 5: candidate dir already in LAUNCHER $PATH is still baked into pane ──
|
||||
#
|
||||
# Regression guard for the bug where _build_runtime_bin_prefix() used to skip
|
||||
# a candidate because it was already present in the launcher process's $PATH.
|
||||
# That check was wrong: the pane inherits the tmux SERVER environment, not the
|
||||
# launcher's env. Even if a dir is on the launcher's PATH it must always be
|
||||
# baked into the pane's PATH export.
|
||||
#
|
||||
# We prove this by setting PATH to include FAKE_RUNTIME_BIN5 (the candidate),
|
||||
# then asserting the generated new-session command still exports it.
|
||||
TMUX_ARGS_FILE5=$(mktemp)
|
||||
FAKE_BIN5=$(mktemp -d)
|
||||
FAKE_RUNTIME_BIN5=$(mktemp -d) # this dir IS on the launcher's PATH below
|
||||
HB_RUN_DIR5=$(mktemp -d)
|
||||
CLEANUP_DIRS+=("$FAKE_BIN5" "$FAKE_RUNTIME_BIN5" "$HB_RUN_DIR5")
|
||||
|
||||
cat > "$FAKE_BIN5/tmux" <<SHIM5
|
||||
#!/usr/bin/env bash
|
||||
subcmd="\$3"
|
||||
if [ "\$subcmd" = "has-session" ]; then exit 1; fi
|
||||
if [ "\$subcmd" = "new-session" ]; then
|
||||
printf '%s\n' "\$@" > "$TMUX_ARGS_FILE5"
|
||||
exit 0
|
||||
fi
|
||||
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before symlinked parent rejection"
|
||||
echo "$output" | grep -qF 'code=unsafe-directory' || fail "symlinked parent diagnostic missing"
|
||||
if [ "\$subcmd" = "list-panes" ]; then
|
||||
# Return empty: no sidecar spawned (heartbeat is not the focus of this test).
|
||||
echo ""
|
||||
exit 0
|
||||
fi
|
||||
exit 0
|
||||
SHIM5
|
||||
chmod +x "$FAKE_BIN5/tmux"
|
||||
|
||||
# Every managed ancestor is a boundary: MOSAIC_HOME, fleet, and agents. A
|
||||
# symlink or group/world-writable ancestor must fail before environment parsing,
|
||||
# workdir creation, or tmux effects. The malformed local input proves parsing
|
||||
# was not reached when the ancestor rejection is reported.
|
||||
assert_managed_ancestor_rejected() {
|
||||
local ancestor="$1"
|
||||
local hazard="$2"
|
||||
local home="$ROOT/managed-${ancestor//\//-}-${hazard}"
|
||||
local agent="coder-managed-${ancestor//\//-}-${hazard}"
|
||||
local node
|
||||
write_generated "$home" "$agent"
|
||||
printf 'MOSAIC_AGENT_COMMAND=must-not-be-parsed\n' > "$home/fleet/agents/$agent.env.local"
|
||||
chmod 600 "$home/fleet/agents/$agent.env.local"
|
||||
rm -rf "$home/work"
|
||||
SOCKET5="mosaic-agent-test5-$RANDOM-$$"
|
||||
AGENT5="agent5-$RANDOM"
|
||||
WORKDIR5=$(mktemp -d)
|
||||
CLEANUP_DIRS+=("$WORKDIR5")
|
||||
CLEANUP_SOCKETS+=("$SOCKET5")
|
||||
|
||||
case "$ancestor" in
|
||||
MOSAIC_HOME) node="$home" ;;
|
||||
MOSAIC_HOME/fleet) node="$home/fleet" ;;
|
||||
MOSAIC_HOME/fleet/agents) node="$home/fleet/agents" ;;
|
||||
*) fail "unknown managed ancestor: $ancestor" ;;
|
||||
esac
|
||||
# FAKE_RUNTIME_BIN5 is deliberately placed on the LAUNCHER PATH so that the
|
||||
# old (buggy) code would have skipped it. The correct code must still include
|
||||
# it in the pane PATH export.
|
||||
PATH="$FAKE_BIN5:$FAKE_RUNTIME_BIN5:$PATH" \
|
||||
MOSAIC_TMUX_SOCKET="$SOCKET5" \
|
||||
MOSAIC_AGENT_WORKDIR="$WORKDIR5" \
|
||||
MOSAIC_AGENT_RUNTIME="pi" \
|
||||
MOSAIC_RUNTIME_BIN="$FAKE_RUNTIME_BIN5" \
|
||||
MOSAIC_AGENT_COMMAND="mosaic yolo pi" \
|
||||
MOSAIC_HEARTBEAT_RUN_DIR="$HB_RUN_DIR5" \
|
||||
"$START" "$AGENT5"
|
||||
|
||||
if [ "$hazard" = symlink ]; then
|
||||
local target="${node}-target"
|
||||
mv "$node" "$target"
|
||||
ln -s "$target" "$node"
|
||||
else
|
||||
chmod 777 "$node"
|
||||
fi
|
||||
all_args5=$(cat "$TMUX_ARGS_FILE5" 2>/dev/null || true)
|
||||
rm -f "$TMUX_ARGS_FILE5"
|
||||
rm -rf "$WORKDIR5"
|
||||
|
||||
: > "$TMUX_CALLS"
|
||||
if output=$(run_start "$home" "$agent" 2>&1); then
|
||||
fail "${hazard} $ancestor was accepted"
|
||||
fi
|
||||
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before $hazard $ancestor rejection"
|
||||
[ ! -e "$home/work" ] || fail "workdir was created before $hazard $ancestor rejection"
|
||||
echo "$output" | grep -qF "code=unsafe-" || fail "managed ancestor diagnostic missing"
|
||||
if echo "$output" | grep -qF 'key=MOSAIC_AGENT_COMMAND'; then
|
||||
fail "environment parsing ran before $hazard $ancestor rejection"
|
||||
fi
|
||||
}
|
||||
echo "--- test 5: launcher-PATH candidate must still appear in pane export ---"
|
||||
echo "$all_args5"
|
||||
echo "--- end test 5 args ---"
|
||||
|
||||
for managed_ancestor in MOSAIC_HOME MOSAIC_HOME/fleet MOSAIC_HOME/fleet/agents; do
|
||||
assert_managed_ancestor_rejected "$managed_ancestor" symlink
|
||||
assert_managed_ancestor_rejected "$managed_ancestor" group-world-writable
|
||||
echo "$all_args5" | grep -qF "export PATH=" || \
|
||||
fail "test5: pane command does not export PATH when candidate is on launcher PATH"
|
||||
echo "$all_args5" | grep -qF "$FAKE_RUNTIME_BIN5" || \
|
||||
fail "test5: candidate dir (already on launcher PATH) was NOT baked into pane PATH — regression"
|
||||
|
||||
# ── Test 6: heartbeat sidecar — pane PID resolved + .hb file written ──────────
|
||||
#
|
||||
# Uses a real tmux session (same socket as test 1 which already has $AGENT) so
|
||||
# list-panes returns a real pane PID. We override MOSAIC_HEARTBEAT_RUN_DIR to
|
||||
# a temp dir and set a 1-second interval, then wait up to 3 s for the .hb file
|
||||
# to appear and check its content.
|
||||
|
||||
HB_RUN_DIR=$(mktemp -d)
|
||||
CLEANUP_DIRS+=("$HB_RUN_DIR")
|
||||
|
||||
# Re-use the session+agent created in Test 1 (still alive on $SOCKET / $AGENT).
|
||||
# We need to invoke the script for a NEW agent on the same socket to exercise
|
||||
# the heartbeat path with a real pane PID.
|
||||
AGENT6="agent6-$RANDOM"
|
||||
MOSAIC_TMUX_SOCKET="$SOCKET" \
|
||||
MOSAIC_AGENT_WORKDIR="$WORKDIR" \
|
||||
MOSAIC_AGENT_COMMAND='bash --noprofile --norc -i' \
|
||||
MOSAIC_HEARTBEAT_RUN_DIR="$HB_RUN_DIR" \
|
||||
MOSAIC_HEARTBEAT_INTERVAL="1" \
|
||||
"$START" "$AGENT6"
|
||||
|
||||
HB_FILE="$HB_RUN_DIR/${AGENT6}.hb"
|
||||
|
||||
# Wait up to 5 seconds for the heartbeat file to appear.
|
||||
_waited=0
|
||||
until [ -f "$HB_FILE" ] || [ "$_waited" -ge 5 ]; do
|
||||
sleep 0.5
|
||||
_waited=$((_waited + 1))
|
||||
done
|
||||
|
||||
# A local file cannot shadow any roster-derived generated key. Validation must
|
||||
# happen before fake tmux receives even a has-session call.
|
||||
: > "$TMUX_CALLS"
|
||||
HOME_SHADOW="$ROOT/shadow"
|
||||
write_generated "$HOME_SHADOW" "coder1"
|
||||
printf 'MOSAIC_AGENT_RUNTIME=codex\n' > "$HOME_SHADOW/fleet/agents/coder1.env.local"
|
||||
chmod 600 "$HOME_SHADOW/fleet/agents/coder1.env.local"
|
||||
if output=$(run_start "$HOME_SHADOW" coder1 2>&1); then
|
||||
fail "generated-key shadow was accepted"
|
||||
fi
|
||||
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before generated-key shadow rejection"
|
||||
echo "$output" | grep -qF 'key=MOSAIC_AGENT_RUNTIME' || fail "shadow diagnostic omitted key"
|
||||
echo "$output" | grep -qF 'sha256=' || fail "shadow diagnostic omitted hash"
|
||||
if echo "$output" | grep -qF 'codex'; then
|
||||
fail "shadow diagnostic leaked value"
|
||||
fi
|
||||
[ -f "$HB_FILE" ] || fail "test6: heartbeat file not written at $HB_FILE within 5s"
|
||||
|
||||
# Arbitrary command compatibility is quarantined/rejected as data. Diagnostics
|
||||
# may name the key and hash but must never echo the privileged command text.
|
||||
: > "$TMUX_CALLS"
|
||||
HOME_COMMAND="$ROOT/command"
|
||||
write_generated "$HOME_COMMAND" "coder2"
|
||||
COMMAND_VALUE='mosaic yolo codex --dangerous'
|
||||
printf 'MOSAIC_AGENT_COMMAND=%s\n' "$COMMAND_VALUE" > "$HOME_COMMAND/fleet/agents/coder2.env.local"
|
||||
chmod 600 "$HOME_COMMAND/fleet/agents/coder2.env.local"
|
||||
if output=$(run_start "$HOME_COMMAND" coder2 2>&1); then
|
||||
fail "arbitrary command override was accepted"
|
||||
fi
|
||||
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before command rejection"
|
||||
echo "$output" | grep -qF 'key=MOSAIC_AGENT_COMMAND' || fail "command diagnostic omitted key"
|
||||
echo "$output" | grep -qF 'sha256=' || fail "command diagnostic omitted hash"
|
||||
if echo "$output" | grep -qF "$COMMAND_VALUE"; then
|
||||
fail "command diagnostic leaked command value"
|
||||
fi
|
||||
hb_content=$(cat "$HB_FILE")
|
||||
echo "--- test 6: heartbeat file content ---"
|
||||
echo "$hb_content"
|
||||
echo "--- end test 6 ---"
|
||||
|
||||
# Group/world-readable local input is not trusted even when its syntax is safe.
|
||||
: > "$TMUX_CALLS"
|
||||
HOME_PERMS="$ROOT/perms"
|
||||
write_generated "$HOME_PERMS" "coder3"
|
||||
printf 'MOSAIC_RUNTIME_BIN=/opt/mosaic/bin\n' > "$HOME_PERMS/fleet/agents/coder3.env.local"
|
||||
chmod 644 "$HOME_PERMS/fleet/agents/coder3.env.local"
|
||||
if output=$(run_start "$HOME_PERMS" coder3 2>&1); then
|
||||
fail "world-readable local input was accepted"
|
||||
fi
|
||||
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before permissions rejection"
|
||||
echo "$output" | grep -qF 'code=unsafe-permissions' || fail "permission diagnostic missing"
|
||||
# Verify required fields are present.
|
||||
echo "$hb_content" | grep -qE '^ts=[0-9]{4}-[0-9]{2}-[0-9]{2}T' || \
|
||||
fail "test6: heartbeat ts field missing or malformed"
|
||||
echo "$hb_content" | grep -qE '^pid=[0-9]+' || \
|
||||
fail "test6: heartbeat pid field missing or malformed"
|
||||
echo "$hb_content" | grep -qF 'status=ok' || \
|
||||
fail "test6: heartbeat status=ok missing"
|
||||
|
||||
# A unit/holder-like clean bootstrap must yield a pane with trusted HOME and
|
||||
# computed PATH only. The pane command itself must not carry loader, shell
|
||||
# control, arbitrary sentinel, or stale bootstrap variables.
|
||||
: > "$TMUX_CALLS"
|
||||
HOME_PANE_BOUNDARY="$ROOT/pane-boundary/.config/mosaic"
|
||||
write_generated "$HOME_PANE_BOUNDARY" "coder-pane-boundary"
|
||||
PANE_TRUSTED_HOME="${HOME_PANE_BOUNDARY%/.config/mosaic}"
|
||||
PANE_STALE_HOME="$ROOT/stale-home"
|
||||
PANE_STALE_PATH="$ROOT/stale-bin"
|
||||
PANE_BASH_ENV="$ROOT/pane-boundary.bash-env"
|
||||
printf 'MOSAIC_RUNTIME_BIN=%s\n' "$FAKE_BIN" > \
|
||||
"$HOME_PANE_BOUNDARY/fleet/agents/coder-pane-boundary.env.local"
|
||||
chmod 600 "$HOME_PANE_BOUNDARY/fleet/agents/coder-pane-boundary.env.local"
|
||||
LD_PRELOAD='/not/loaded/by-clean-bootstrap.so' \
|
||||
BASH_ENV="$PANE_BASH_ENV" \
|
||||
MOSAIC_UNTRUSTED_SENTINEL='must-not-reach-pane' \
|
||||
HOME="$PANE_STALE_HOME" \
|
||||
PATH="$PANE_STALE_PATH" \
|
||||
/usr/bin/env -i \
|
||||
"HOME=$PANE_TRUSTED_HOME" \
|
||||
"PATH=$FAKE_BIN:/usr/bin:/bin" \
|
||||
"MOSAIC_HOME=$HOME_PANE_BOUNDARY" \
|
||||
"MOSAIC_TEST_TMUX_CALLS=$TMUX_CALLS" \
|
||||
"MOSAIC_TEST_HOME=$PANE_TRUSTED_HOME" \
|
||||
MOSAIC_TEST_FLEET_OWNER=123e4567-e89b-12d3-a456-426614174000 \
|
||||
MOSAIC_TEST_EXECUTE_PANE=1 \
|
||||
"$START" coder-pane-boundary
|
||||
pane_args=$(tr '\0' '\n' < "$TMUX_CALLS")
|
||||
echo "$pane_args" | grep -qxF "HOME=$PANE_TRUSTED_HOME" || \
|
||||
fail "pane did not restore trusted HOME"
|
||||
echo "$pane_args" | grep -qF "HOME=$PANE_STALE_HOME" && \
|
||||
fail "pane inherited stale HOME"
|
||||
echo "$pane_args" | grep -qF "$PANE_STALE_PATH" && fail "pane inherited stale PATH"
|
||||
for blocked in LD_PRELOAD= BASH_ENV= MOSAIC_UNTRUSTED_SENTINEL=; do
|
||||
echo "$pane_args" | grep -qF "$blocked" && fail "pane inherited $blocked"
|
||||
done
|
||||
# ── Test 7: heartbeat sidecar — targets correct .hb path per agent name ────────
|
||||
#
|
||||
# Uses the fake-tmux shim approach (like tests 3-5) to capture the sidecar
|
||||
# invocation without needing a real session. A fake setsid shim records its
|
||||
# arguments so we can assert the sidecar script targets the expected .hb path
|
||||
# and uses the configured interval.
|
||||
|
||||
after_pane_env=$(printf '%s\n' "$pane_args" | grep -n -m1 -F '/usr/bin/env' | cut -d: -f1)
|
||||
[ -n "$after_pane_env" ] || fail "pane command did not use absolute env"
|
||||
printf '%s\n' "$pane_args" | tail -n +"$after_pane_env" | grep -qxF -- '-i' || \
|
||||
fail "pane command did not clear its environment"
|
||||
pane_environment=$(tr '\0' '\n' < "$HOME_PANE_BOUNDARY/fleet/pane-environment")
|
||||
echo "$pane_environment" | grep -qxF "HOME=$PANE_TRUSTED_HOME" || \
|
||||
fail "runtime pane did not receive trusted HOME"
|
||||
echo "$pane_environment" | grep -qF "$PANE_STALE_PATH" && fail "runtime pane received stale PATH"
|
||||
for blocked in LD_PRELOAD= BASH_ENV= MOSAIC_UNTRUSTED_SENTINEL=; do
|
||||
echo "$pane_environment" | grep -qF "$blocked" && fail "runtime pane received $blocked"
|
||||
done
|
||||
FAKE_BIN7=$(mktemp -d)
|
||||
FAKE_RUNTIME_BIN7=$(mktemp -d)
|
||||
SETSID_ARGS_FILE=$(mktemp)
|
||||
HB_RUN_DIR7=$(mktemp -d)
|
||||
CLEANUP_DIRS+=("$FAKE_BIN7" "$FAKE_RUNTIME_BIN7" "$HB_RUN_DIR7")
|
||||
|
||||
write_interaction_generated() {
|
||||
local home="$1"
|
||||
local agent="$2"
|
||||
mkdir -p "$home/fleet/agents" "$home/fleet/run" "$home/work"
|
||||
chmod 700 "$home" "$home/fleet" "$home/fleet/agents" "$home/fleet/run"
|
||||
printf '123e4567-e89b-12d3-a456-426614174000\n' > "$home/fleet/run/holder-owner"
|
||||
chmod 600 "$home/fleet/run/holder-owner"
|
||||
cat > "$home/fleet/agents/$agent.env.generated" <<EOF
|
||||
MOSAIC_AGENT_NAME=$agent
|
||||
MOSAIC_AGENT_CLASS=operator-interaction
|
||||
MOSAIC_AGENT_RUNTIME=pi
|
||||
MOSAIC_AGENT_MODEL=openai/gpt-5.6-sol
|
||||
MOSAIC_AGENT_REASONING=high
|
||||
MOSAIC_AGENT_TOOL_POLICY=operator-interaction
|
||||
MOSAIC_AGENT_WORKDIR=$home/work
|
||||
MOSAIC_TMUX_SOCKET=mosaic-test
|
||||
EOF
|
||||
chmod 600 "$home/fleet/agents/$agent.env.generated"
|
||||
}
|
||||
AGENT7="my-fleet-agent-$RANDOM"
|
||||
INTERVAL7="42"
|
||||
|
||||
run_interaction() {
|
||||
local home="$1"
|
||||
local agent="$2"
|
||||
HOME="$home" PATH="$FAKE_BIN:$PATH" MOSAIC_TEST_TMUX_CALLS="$TMUX_CALLS" \
|
||||
MOSAIC_TEST_HOME="$home" \
|
||||
MOSAIC_TEST_FLEET_OWNER=123e4567-e89b-12d3-a456-426614174000 \
|
||||
MOSAIC_HOME="$home" "$INTERACTION_START" "$agent"
|
||||
}
|
||||
# Fake tmux: has-session → not found; new-session → ok; list-panes → known PID.
|
||||
cat > "$FAKE_BIN7/tmux" <<SHIM7
|
||||
#!/usr/bin/env bash
|
||||
subcmd="\$3"
|
||||
if [ "\$subcmd" = "has-session" ]; then exit 1; fi
|
||||
if [ "\$subcmd" = "new-session" ]; then exit 0; fi
|
||||
if [ "\$subcmd" = "list-panes" ]; then echo "88888"; exit 0; fi
|
||||
exit 0
|
||||
SHIM7
|
||||
chmod +x "$FAKE_BIN7/tmux"
|
||||
|
||||
write_heartbeat_local() {
|
||||
local home="$1"
|
||||
local agent="$2"
|
||||
mkdir -p "$home/run"
|
||||
cat > "$home/fleet/agents/$agent.env.local" <<EOF
|
||||
MOSAIC_HEARTBEAT_RUN_DIR=$home/run
|
||||
MOSAIC_HEARTBEAT_INTERVAL=1
|
||||
EOF
|
||||
chmod 600 "$home/fleet/agents/$agent.env.local"
|
||||
}
|
||||
# Fake setsid: capture the bash -c <script> argument for inspection, then
|
||||
# background an actual bash subshell so disown succeeds in the caller.
|
||||
cat > "$FAKE_BIN7/setsid" <<'SETSID_SHIM'
|
||||
#!/usr/bin/env bash
|
||||
# argv: setsid bash -c <sidecar_script>
|
||||
# Record the full argument list to the capture file, then exit cleanly.
|
||||
printf '%s\0' "$@" > __SETSID_ARGS_FILE__
|
||||
exit 0
|
||||
SETSID_SHIM
|
||||
# Patch the placeholder with the real capture-file path (avoids heredoc expansion issues).
|
||||
sed -i "s|__SETSID_ARGS_FILE__|${SETSID_ARGS_FILE}|g" "$FAKE_BIN7/setsid"
|
||||
chmod +x "$FAKE_BIN7/setsid"
|
||||
|
||||
wait_for_sidecar_status() {
|
||||
local file="$1"
|
||||
for _retry in $(seq 1 30); do
|
||||
grep -qF 'status=ok' "$file" 2>/dev/null && return 0
|
||||
sleep 0.1
|
||||
done
|
||||
fail "heartbeat sidecar did not resume after native marker became stale or absent"
|
||||
}
|
||||
SOCKET7="mosaic-agent-test7-$RANDOM-$$"
|
||||
WORKDIR7=$(mktemp -d)
|
||||
CLEANUP_DIRS+=("$WORKDIR7")
|
||||
|
||||
# A fresh Pi-native marker is authoritative: the shell sidecar may start but
|
||||
# must not overwrite Pi's busy/ok/model heartbeat. It must resume only when
|
||||
# the marker is stale or absent.
|
||||
HOME_NATIVE_FRESH="$ROOT/native-fresh"
|
||||
write_generated "$HOME_NATIVE_FRESH" "coder-native-fresh"
|
||||
write_heartbeat_local "$HOME_NATIVE_FRESH" "coder-native-fresh"
|
||||
FRESH_HB="$HOME_NATIVE_FRESH/run/coder-native-fresh.hb"
|
||||
printf 'ts=native\npid=1\nstatus=busy\nmodel=authoritative-model\n' > "$FRESH_HB"
|
||||
touch "$FRESH_HB.native"
|
||||
MOSAIC_TEST_PANE_PID=$$ run_start "$HOME_NATIVE_FRESH" coder-native-fresh
|
||||
sleep 0.3
|
||||
fresh_content=$(cat "$FRESH_HB")
|
||||
[ "$fresh_content" = 'ts=native
|
||||
pid=1
|
||||
status=busy
|
||||
model=authoritative-model' ] || fail "fresh native heartbeat was overwritten"
|
||||
PATH="$FAKE_BIN7:$PATH" \
|
||||
MOSAIC_TMUX_SOCKET="$SOCKET7" \
|
||||
MOSAIC_AGENT_WORKDIR="$WORKDIR7" \
|
||||
MOSAIC_AGENT_RUNTIME="pi" \
|
||||
MOSAIC_RUNTIME_BIN="$FAKE_RUNTIME_BIN7" \
|
||||
MOSAIC_AGENT_COMMAND="mosaic yolo pi" \
|
||||
MOSAIC_HEARTBEAT_RUN_DIR="$HB_RUN_DIR7" \
|
||||
MOSAIC_HEARTBEAT_INTERVAL="$INTERVAL7" \
|
||||
"$START" "$AGENT7"
|
||||
|
||||
HOME_NATIVE_STALE="$ROOT/native-stale"
|
||||
write_generated "$HOME_NATIVE_STALE" "coder-native-stale"
|
||||
write_heartbeat_local "$HOME_NATIVE_STALE" "coder-native-stale"
|
||||
STALE_HB="$HOME_NATIVE_STALE/run/coder-native-stale.hb"
|
||||
printf 'ts=native\npid=1\nstatus=busy\nmodel=stale-model\n' > "$STALE_HB"
|
||||
touch -d '10 seconds ago' "$STALE_HB.native"
|
||||
MOSAIC_TEST_PANE_PID=$$ run_start "$HOME_NATIVE_STALE" coder-native-stale
|
||||
wait_for_sidecar_status "$STALE_HB"
|
||||
# Give the background setsid shim a moment to finish writing the capture file.
|
||||
sleep 0.5
|
||||
|
||||
HOME_NATIVE_ABSENT="$ROOT/native-absent"
|
||||
write_generated "$HOME_NATIVE_ABSENT" "coder-native-absent"
|
||||
write_heartbeat_local "$HOME_NATIVE_ABSENT" "coder-native-absent"
|
||||
ABSENT_HB="$HOME_NATIVE_ABSENT/run/coder-native-absent.hb"
|
||||
printf 'ts=native\npid=1\nstatus=busy\nmodel=absent-model\n' > "$ABSENT_HB"
|
||||
MOSAIC_TEST_PANE_PID=$$ run_start "$HOME_NATIVE_ABSENT" coder-native-absent
|
||||
wait_for_sidecar_status "$ABSENT_HB"
|
||||
setsid_args=$(cat "$SETSID_ARGS_FILE" 2>/dev/null | tr '\0' '\n' || true)
|
||||
rm -f "$SETSID_ARGS_FILE"
|
||||
rm -rf "$WORKDIR7"
|
||||
|
||||
# The interaction wrapper delegates to the shared strict parser before applying
|
||||
# its pinned policy, so malformed projection data wins over profile diagnostics.
|
||||
: > "$TMUX_CALLS"
|
||||
HOME_INTERACTION_MALFORMED="$ROOT/interaction-malformed"
|
||||
write_interaction_generated "$HOME_INTERACTION_MALFORMED" "interaction-malformed"
|
||||
printf 'UNTRUSTED_BOOTSTRAP=value\n' >> "$HOME_INTERACTION_MALFORMED/fleet/agents/interaction-malformed.env.generated"
|
||||
if output=$(run_interaction "$HOME_INTERACTION_MALFORMED" interaction-malformed 2>&1); then
|
||||
fail "interaction wrapper accepted malformed generated data"
|
||||
fi
|
||||
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before interaction strict-parser rejection"
|
||||
echo "$output" | grep -qF 'code=unknown-key' || fail "interaction did not use shared strict parser first"
|
||||
echo "--- test 7: captured setsid args ---"
|
||||
echo "$setsid_args"
|
||||
echo "--- end test 7 ---"
|
||||
|
||||
# A syntactically valid but policy-incompatible projection reaches the pinned
|
||||
# interaction policy check only after strict parsing and never starts tmux.
|
||||
: > "$TMUX_CALLS"
|
||||
HOME_INTERACTION_POLICY="$ROOT/interaction-policy"
|
||||
write_interaction_generated "$HOME_INTERACTION_POLICY" "interaction-policy"
|
||||
perl -0pi -e 's/MOSAIC_AGENT_RUNTIME=pi/MOSAIC_AGENT_RUNTIME=codex/' \
|
||||
"$HOME_INTERACTION_POLICY/fleet/agents/interaction-policy.env.generated"
|
||||
if output=$(run_interaction "$HOME_INTERACTION_POLICY" interaction-policy 2>&1); then
|
||||
fail "interaction wrapper accepted a policy-incompatible projection"
|
||||
fi
|
||||
interaction_policy_args=$(tr '\0' '\n' < "$TMUX_CALLS")
|
||||
echo "$interaction_policy_args" | grep -qF 'new-session' && \
|
||||
fail "interaction pinned-policy rejection created a tmux session"
|
||||
echo "$output" | grep -qF 'operator interaction service requires runtime pi' || \
|
||||
fail "interaction pinned-policy check did not follow strict parsing"
|
||||
# The sidecar script (bash -c <script>) must reference the correct .hb path.
|
||||
expected_hb="${HB_RUN_DIR7}/${AGENT7}.hb"
|
||||
echo "$setsid_args" | grep -qF "$expected_hb" || \
|
||||
fail "test7: sidecar script does not reference correct .hb path ($expected_hb)"
|
||||
|
||||
# Exact stop derives the socket exclusively from the validated generated
|
||||
# projection and ignores an ambient socket supplied by the caller.
|
||||
: > "$TMUX_CALLS"
|
||||
HOME_STOP="$ROOT/stop"
|
||||
write_generated "$HOME_STOP" "coder-stop"
|
||||
HOME="$HOME_STOP" PATH="$FAKE_BIN:$PATH" MOSAIC_TEST_TMUX_CALLS="$TMUX_CALLS" \
|
||||
MOSAIC_TEST_HOME="$HOME_STOP" \
|
||||
MOSAIC_TEST_FLEET_OWNER=123e4567-e89b-12d3-a456-426614174000 \
|
||||
MOSAIC_HOME="$HOME_STOP" MOSAIC_TMUX_SOCKET=ambient-socket "$START" --stop coder-stop
|
||||
stop_args=$(tr '\0' '\n' < "$TMUX_CALLS")
|
||||
echo "$stop_args" | grep -qxF 'mosaic-test' || fail "exact stop did not use the validated generated socket"
|
||||
echo "$stop_args" | grep -qxF 'kill-session' || fail "exact stop did not request session termination"
|
||||
echo "$stop_args" | grep -qxF '=coder-stop' || fail "exact stop did not exact-match the generated agent name"
|
||||
if echo "$stop_args" | grep -qF 'ambient-socket'; then
|
||||
fail "exact stop trusted an ambient socket"
|
||||
fi
|
||||
# The sidecar script must use the configured interval.
|
||||
echo "$setsid_args" | grep -qF "$INTERVAL7" || \
|
||||
fail "test7: sidecar script does not reference configured interval ($INTERVAL7)"
|
||||
|
||||
echo 'ok - start-agent-session generated environment boundary'
|
||||
echo "ok - start-agent-session"
|
||||
|
||||
@@ -1,657 +0,0 @@
|
||||
import { chmod, mkdir, mkdtemp, readFile, rename, rm, symlink, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Command } from 'commander';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fleetAgentMutationExitCode } from './fleet-agent-crud-command.js';
|
||||
import { registerFleetCommand, type FleetCommandDeps } from './fleet.js';
|
||||
|
||||
const roster = `
|
||||
version: 2
|
||||
generation: 7
|
||||
transport: tmux
|
||||
tmux:
|
||||
socket_name: mosaic-fleet
|
||||
holder_session: _holder
|
||||
defaults:
|
||||
working_directory: /srv/mosaic
|
||||
runtime: pi
|
||||
runtimes:
|
||||
pi:
|
||||
reset_command: /new
|
||||
agents:
|
||||
- name: orchestrator
|
||||
alias: Orchestrator
|
||||
class: orchestrator
|
||||
runtime: pi
|
||||
provider: openai
|
||||
model: gpt-5.6-sol
|
||||
reasoning: high
|
||||
tool_policy: orchestrator
|
||||
working_directory: /srv/mosaic
|
||||
persistent_persona: true
|
||||
reset_between_tasks: false
|
||||
lifecycle:
|
||||
enabled: true
|
||||
desired_state: stopped
|
||||
launch:
|
||||
yolo: true
|
||||
`;
|
||||
|
||||
const agent = {
|
||||
name: 'coder0',
|
||||
alias: 'Coder 0',
|
||||
className: 'code',
|
||||
runtime: 'pi',
|
||||
provider: 'openai',
|
||||
model: 'gpt-5.6-sol',
|
||||
reasoning: 'high',
|
||||
toolPolicy: 'code',
|
||||
workingDirectory: '/srv/mosaic',
|
||||
persistentPersona: false,
|
||||
resetBetweenTasks: true,
|
||||
launch: { yolo: true },
|
||||
};
|
||||
|
||||
let cleanup: string | undefined;
|
||||
|
||||
afterEach(async (): Promise<void> => {
|
||||
vi.restoreAllMocks();
|
||||
process.exitCode = undefined;
|
||||
if (cleanup) await rm(cleanup, { recursive: true, force: true });
|
||||
cleanup = undefined;
|
||||
});
|
||||
|
||||
async function fleetHome(): Promise<string> {
|
||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-fleet-crud-command-'));
|
||||
const fleetDir = join(cleanup, 'fleet');
|
||||
const agentsDir = join(fleetDir, 'agents');
|
||||
const rolesDir = join(fleetDir, 'roles');
|
||||
await mkdir(agentsDir, { recursive: true, mode: 0o700 });
|
||||
await mkdir(rolesDir, { recursive: true, mode: 0o700 });
|
||||
await chmod(cleanup, 0o700);
|
||||
await chmod(fleetDir, 0o700);
|
||||
await chmod(agentsDir, 0o700);
|
||||
await chmod(rolesDir, 0o700);
|
||||
await writeFile(join(fleetDir, 'roster.yaml'), roster, { mode: 0o600 });
|
||||
for (const className of ['orchestrator', 'code']) {
|
||||
await writeFile(
|
||||
join(rolesDir, `${className}.md`),
|
||||
`# ${className}\n\n(\`class: ${className}\`)\n`,
|
||||
{
|
||||
mode: 0o600,
|
||||
},
|
||||
);
|
||||
}
|
||||
return cleanup;
|
||||
}
|
||||
|
||||
function program(mosaicHome: string, deps: FleetCommandDeps = {}): Command {
|
||||
const result = new Command();
|
||||
result.exitOverride();
|
||||
registerFleetCommand(result, { ...deps, mosaicHome });
|
||||
return result;
|
||||
}
|
||||
|
||||
function output(): { lines: string[] } {
|
||||
const lines: string[] = [];
|
||||
vi.spyOn(console, 'log').mockImplementation((value: string): void => {
|
||||
lines.push(value);
|
||||
});
|
||||
return { lines };
|
||||
}
|
||||
|
||||
describe('mosaic fleet local roster mutations', (): void => {
|
||||
it('returns non-zero when a projection failure leaves recovery work after roster persistence', (): void => {
|
||||
expect(
|
||||
fleetAgentMutationExitCode({
|
||||
recovery: {
|
||||
code: 'projection-apply-failed',
|
||||
rosterPath: '/private/fleet/roster.yaml',
|
||||
action: 'regenerate-projections-from-roster',
|
||||
},
|
||||
}),
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it('returns zero for a mutation result without recovery work', (): void => {
|
||||
expect(fleetAgentMutationExitCode({})).toBe(0);
|
||||
});
|
||||
|
||||
it('registers programmatic local create, get, update, delete, and plan commands', async (): Promise<void> => {
|
||||
const home = await fleetHome();
|
||||
const root = program(home);
|
||||
const fleet = root.commands.find((candidate: Command): boolean => candidate.name() === 'fleet');
|
||||
expect(fleet?.commands.map((candidate: Command): string => candidate.name())).toEqual(
|
||||
expect.arrayContaining(['create', 'delete', 'get', 'plan', 'update']),
|
||||
);
|
||||
});
|
||||
|
||||
it('runs the JSON mutation commands through the direct mosaic fleet control plane', async (): Promise<void> => {
|
||||
const home = await fleetHome();
|
||||
const root = new Command();
|
||||
root.exitOverride();
|
||||
registerFleetCommand(root, { mosaicHome: home });
|
||||
const fleet = root.commands.find((candidate: Command): boolean => candidate.name() === 'fleet');
|
||||
expect(fleet?.commands.map((candidate: Command): string => candidate.name())).toEqual(
|
||||
expect.arrayContaining(['create', 'delete', 'get', 'plan', 'update']),
|
||||
);
|
||||
});
|
||||
|
||||
it('gets one v2 roster agent as stable JSON without mutating the roster', async (): Promise<void> => {
|
||||
const home = await fleetHome();
|
||||
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
||||
const captured = output();
|
||||
|
||||
const root = new Command();
|
||||
root.exitOverride();
|
||||
registerFleetCommand(root, { mosaicHome: home });
|
||||
await root.parseAsync(['node', 'mosaic', 'fleet', 'get', 'orchestrator']);
|
||||
|
||||
expect(JSON.parse(captured.lines.join('\n'))).toMatchObject({
|
||||
generation: 7,
|
||||
agent: { name: 'orchestrator', lifecycle: { desiredState: 'stopped' } },
|
||||
});
|
||||
expect(await readFile(rosterPath, 'utf8')).toBe(roster);
|
||||
});
|
||||
|
||||
it('returns a JSON error and non-zero exit for a missing local agent', async (): Promise<void> => {
|
||||
const home = await fleetHome();
|
||||
const captured = output();
|
||||
|
||||
await program(home).parseAsync(['node', 'mosaic', 'fleet', 'get', 'missing']);
|
||||
|
||||
expect(JSON.parse(captured.lines.join('\n'))).toEqual({ error: { code: 'agent-not-found' } });
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it('prints a deterministic create plan without changing roster or generated projections', async (): Promise<void> => {
|
||||
const home = await fleetHome();
|
||||
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
||||
const captured = output();
|
||||
|
||||
await program(home).parseAsync([
|
||||
'node',
|
||||
'mosaic',
|
||||
'fleet',
|
||||
'plan',
|
||||
'create',
|
||||
'--expected-generation',
|
||||
'7',
|
||||
'--agent',
|
||||
JSON.stringify(agent),
|
||||
]);
|
||||
|
||||
expect(JSON.parse(captured.lines.join('\n'))).toMatchObject({
|
||||
applied: false,
|
||||
plan: {
|
||||
operation: 'create',
|
||||
currentGeneration: 7,
|
||||
nextGeneration: 8,
|
||||
agent: { name: 'coder0', lifecycle: { enabled: true, desiredState: 'stopped' } },
|
||||
},
|
||||
});
|
||||
expect(await readFile(rosterPath, 'utf8')).toBe(roster);
|
||||
await expect(
|
||||
readFile(join(home, 'fleet', 'agents', 'coder0.env.generated'), 'utf8'),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('plans create, update, and delete with operation-specific target names without mutation', async (): Promise<void> => {
|
||||
const home = await fleetHome();
|
||||
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
||||
const captured = output();
|
||||
const updatedCoder = { ...agent, alias: 'Coder Updated' };
|
||||
|
||||
await program(home).parseAsync([
|
||||
'node',
|
||||
'mosaic',
|
||||
'fleet',
|
||||
'plan',
|
||||
'create',
|
||||
'--expected-generation',
|
||||
'7',
|
||||
'--agent',
|
||||
JSON.stringify(agent),
|
||||
]);
|
||||
await program(home).parseAsync([
|
||||
'node',
|
||||
'mosaic',
|
||||
'fleet',
|
||||
'create',
|
||||
'--expected-generation',
|
||||
'7',
|
||||
'--agent',
|
||||
JSON.stringify(agent),
|
||||
]);
|
||||
const beforePlans = await readFile(rosterPath, 'utf8');
|
||||
const beforeOrchestratorProjection = await readFile(
|
||||
join(home, 'fleet', 'agents', 'orchestrator.env.generated'),
|
||||
'utf8',
|
||||
);
|
||||
captured.lines.length = 0;
|
||||
|
||||
await program(home).parseAsync([
|
||||
'node',
|
||||
'mosaic',
|
||||
'fleet',
|
||||
'plan',
|
||||
'update',
|
||||
'coder0',
|
||||
'--expected-generation',
|
||||
'8',
|
||||
'--agent',
|
||||
JSON.stringify(updatedCoder),
|
||||
]);
|
||||
await program(home).parseAsync([
|
||||
'node',
|
||||
'mosaic',
|
||||
'fleet',
|
||||
'plan',
|
||||
'delete',
|
||||
'coder0',
|
||||
'--expected-generation',
|
||||
'8',
|
||||
]);
|
||||
|
||||
const plans = captured.lines.map((line: string): unknown => JSON.parse(line));
|
||||
expect(plans).toMatchObject([
|
||||
{ plan: { operation: 'update', agent: { name: 'coder0' } } },
|
||||
{ plan: { operation: 'delete', agent: { name: 'coder0' } } },
|
||||
]);
|
||||
expect(await readFile(rosterPath, 'utf8')).toBe(beforePlans);
|
||||
expect(
|
||||
await readFile(join(home, 'fleet', 'agents', 'orchestrator.env.generated'), 'utf8'),
|
||||
).toBe(beforeOrchestratorProjection);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['plan create', ['fleet', 'plan', 'create'], 'top-level'],
|
||||
['create', ['fleet', 'create'], 'top-level'],
|
||||
['update', ['fleet', 'update', 'orchestrator'], 'top-level'],
|
||||
['plan create', ['fleet', 'plan', 'create'], 'launch'],
|
||||
['create', ['fleet', 'create'], 'launch'],
|
||||
['update', ['fleet', 'update', 'orchestrator'], 'launch'],
|
||||
])(
|
||||
'rejects unknown and prototype-sensitive agent fields without mutation',
|
||||
async (_operation: string, command: string[], shape: string): Promise<void> => {
|
||||
const home = await fleetHome();
|
||||
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
||||
const agentsDir = join(home, 'fleet', 'agents');
|
||||
const captured = output();
|
||||
const artifactContents = new Map([
|
||||
['orchestrator.env.generated', 'generated-before\n'],
|
||||
['orchestrator.env.local', 'local-before\n'],
|
||||
['orchestrator.env', 'legacy-before\n'],
|
||||
['orchestrator.env.quarantine', 'quarantine-before\n'],
|
||||
]);
|
||||
for (const [filename, content] of artifactContents) {
|
||||
await writeFile(join(agentsDir, filename), content, { mode: 0o600 });
|
||||
}
|
||||
const rejectedValue = 'must-not-appear-in-diagnostics';
|
||||
const unsafeAgent = {
|
||||
...agent,
|
||||
name: 'orchestrator',
|
||||
launch: shape === 'launch' ? { yolo: true, command: rejectedValue } : { yolo: true },
|
||||
...(shape === 'top-level'
|
||||
? {
|
||||
command: rejectedValue,
|
||||
channel: rejectedValue,
|
||||
secretRef: rejectedValue,
|
||||
constructor: rejectedValue,
|
||||
prototype: rejectedValue,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
const payload =
|
||||
shape === 'top-level'
|
||||
? JSON.stringify(unsafeAgent).replace(/}$/, ',"__proto__":{"unsupported":true}}')
|
||||
: JSON.stringify(unsafeAgent);
|
||||
|
||||
await program(home).parseAsync([
|
||||
'node',
|
||||
'mosaic',
|
||||
...command,
|
||||
'--expected-generation',
|
||||
'7',
|
||||
'--agent',
|
||||
payload,
|
||||
]);
|
||||
|
||||
const diagnostic = captured.lines.pop() ?? '';
|
||||
expect(JSON.parse(diagnostic)).toEqual({ error: { code: 'invalid-request' } });
|
||||
expect(diagnostic).not.toContain(rejectedValue);
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(await readFile(rosterPath, 'utf8')).toBe(roster);
|
||||
for (const [filename, content] of artifactContents) {
|
||||
expect(await readFile(join(agentsDir, filename), 'utf8')).toBe(content);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it('rejects missing update/delete plan names and an extra create plan name', async (): Promise<void> => {
|
||||
const home = await fleetHome();
|
||||
const captured = output();
|
||||
|
||||
await program(home).parseAsync([
|
||||
'node',
|
||||
'mosaic',
|
||||
'fleet',
|
||||
'plan',
|
||||
'delete',
|
||||
'--expected-generation',
|
||||
'7',
|
||||
]);
|
||||
expect(JSON.parse(captured.lines.pop() ?? '')).toEqual({ error: { code: 'invalid-request' } });
|
||||
|
||||
await program(home).parseAsync([
|
||||
'node',
|
||||
'mosaic',
|
||||
'fleet',
|
||||
'plan',
|
||||
'create',
|
||||
'coder0',
|
||||
'--expected-generation',
|
||||
'7',
|
||||
'--agent',
|
||||
JSON.stringify(agent),
|
||||
]);
|
||||
expect(JSON.parse(captured.lines.pop() ?? '')).toEqual({ error: { code: 'invalid-request' } });
|
||||
});
|
||||
|
||||
it('plans persisted start as desired state without starting a runtime', async (): Promise<void> => {
|
||||
const home = await fleetHome();
|
||||
const captured = output();
|
||||
|
||||
await program(home).parseAsync([
|
||||
'node',
|
||||
'mosaic',
|
||||
'fleet',
|
||||
'plan',
|
||||
'create',
|
||||
'--expected-generation',
|
||||
'7',
|
||||
'--agent',
|
||||
JSON.stringify(agent),
|
||||
'--persisted-start',
|
||||
]);
|
||||
|
||||
expect(JSON.parse(captured.lines.join('\n'))).toMatchObject({
|
||||
applied: false,
|
||||
plan: { agent: { lifecycle: { desiredState: 'running' } } },
|
||||
});
|
||||
});
|
||||
|
||||
it('returns non-zero JSON when a filesystem projection fails after roster persistence', async (): Promise<void> => {
|
||||
const home = await fleetHome();
|
||||
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
||||
const agentsDir = join(home, 'fleet', 'agents');
|
||||
const captured = output();
|
||||
|
||||
await program(home, {
|
||||
projectionApplier: async (): Promise<never> => {
|
||||
throw new Error('injected projection failure');
|
||||
},
|
||||
}).parseAsync([
|
||||
'node',
|
||||
'mosaic',
|
||||
'fleet',
|
||||
'create',
|
||||
'--expected-generation',
|
||||
'7',
|
||||
'--agent',
|
||||
JSON.stringify(agent),
|
||||
]);
|
||||
|
||||
expect(JSON.parse(captured.lines.join('\n'))).toMatchObject({
|
||||
applied: false,
|
||||
authoritativeRoster: 'committed',
|
||||
projections: 'incomplete',
|
||||
recovery: { code: 'projection-apply-failed', action: 'regenerate-projections-from-roster' },
|
||||
});
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(await readFile(rosterPath, 'utf8')).toContain('generation: 8');
|
||||
expect(await readFile(rosterPath, 'utf8')).toContain('name: coder0');
|
||||
await expect(readFile(join(agentsDir, 'coder0.env.generated'), 'utf8')).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('deletes only the target generated projection while retaining legacy and quarantine artifacts', async (): Promise<void> => {
|
||||
const home = await fleetHome();
|
||||
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
||||
const agentsDir = join(home, 'fleet', 'agents');
|
||||
const captured = output();
|
||||
|
||||
await program(home).parseAsync([
|
||||
'node',
|
||||
'mosaic',
|
||||
'fleet',
|
||||
'create',
|
||||
'--expected-generation',
|
||||
'7',
|
||||
'--agent',
|
||||
JSON.stringify(agent),
|
||||
]);
|
||||
await writeFile(join(agentsDir, 'coder0.env.local'), 'MOSAIC_RUNTIME_BIN=/usr/bin/pi\n', {
|
||||
mode: 0o600,
|
||||
});
|
||||
await writeFile(join(agentsDir, 'coder0.env'), 'MOSAIC_AGENT_COMMAND=legacy-command\n', {
|
||||
mode: 0o600,
|
||||
});
|
||||
await writeFile(join(agentsDir, 'coder0.env.quarantine'), 'quarantine-before\n', {
|
||||
mode: 0o600,
|
||||
});
|
||||
await writeFile(join(agentsDir, 'unrelated.env.generated'), 'unrelated-before\n', {
|
||||
mode: 0o600,
|
||||
});
|
||||
const beforeDryRun = await Promise.all([
|
||||
readFile(rosterPath, 'utf8'),
|
||||
readFile(join(agentsDir, 'coder0.env.generated'), 'utf8'),
|
||||
readFile(join(agentsDir, 'coder0.env.local'), 'utf8'),
|
||||
readFile(join(agentsDir, 'coder0.env'), 'utf8'),
|
||||
readFile(join(agentsDir, 'coder0.env.quarantine'), 'utf8'),
|
||||
readFile(join(agentsDir, 'unrelated.env.generated'), 'utf8'),
|
||||
]);
|
||||
|
||||
await program(home).parseAsync([
|
||||
'node',
|
||||
'mosaic',
|
||||
'fleet',
|
||||
'delete',
|
||||
'coder0',
|
||||
'--expected-generation',
|
||||
'8',
|
||||
'--dry-run',
|
||||
]);
|
||||
expect(JSON.parse(captured.lines.pop() ?? '')).toMatchObject({
|
||||
applied: false,
|
||||
authoritativeRoster: 'unchanged',
|
||||
projections: 'not-applied',
|
||||
});
|
||||
expect(
|
||||
await Promise.all([
|
||||
readFile(rosterPath, 'utf8'),
|
||||
readFile(join(agentsDir, 'coder0.env.generated'), 'utf8'),
|
||||
readFile(join(agentsDir, 'coder0.env.local'), 'utf8'),
|
||||
readFile(join(agentsDir, 'coder0.env'), 'utf8'),
|
||||
readFile(join(agentsDir, 'coder0.env.quarantine'), 'utf8'),
|
||||
readFile(join(agentsDir, 'unrelated.env.generated'), 'utf8'),
|
||||
]),
|
||||
).toEqual(beforeDryRun);
|
||||
|
||||
await program(home).parseAsync([
|
||||
'node',
|
||||
'mosaic',
|
||||
'fleet',
|
||||
'delete',
|
||||
'coder0',
|
||||
'--expected-generation',
|
||||
'8',
|
||||
]);
|
||||
|
||||
expect(JSON.parse(captured.lines.pop() ?? '')).toMatchObject({
|
||||
applied: true,
|
||||
authoritativeRoster: 'committed',
|
||||
projections: 'complete',
|
||||
});
|
||||
await expect(readFile(join(agentsDir, 'coder0.env.generated'), 'utf8')).rejects.toThrow();
|
||||
expect(await readFile(join(agentsDir, 'coder0.env.local'), 'utf8')).toBe(beforeDryRun[2]);
|
||||
expect(await readFile(join(agentsDir, 'coder0.env'), 'utf8')).toBe(beforeDryRun[3]);
|
||||
expect(await readFile(join(agentsDir, 'coder0.env.quarantine'), 'utf8')).toBe(beforeDryRun[4]);
|
||||
expect(await readFile(join(agentsDir, 'unrelated.env.generated'), 'utf8')).toBe(
|
||||
beforeDryRun[5],
|
||||
);
|
||||
expect(await readFile(rosterPath, 'utf8')).toContain('generation: 9');
|
||||
});
|
||||
|
||||
it('reports truthful partial recovery when a delete projection fails after roster persistence', async (): Promise<void> => {
|
||||
const home = await fleetHome();
|
||||
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
||||
const agentsDir = join(home, 'fleet', 'agents');
|
||||
const captured = output();
|
||||
await program(home).parseAsync([
|
||||
'node',
|
||||
'mosaic',
|
||||
'fleet',
|
||||
'create',
|
||||
'--expected-generation',
|
||||
'7',
|
||||
'--agent',
|
||||
JSON.stringify(agent),
|
||||
]);
|
||||
|
||||
captured.lines.length = 0;
|
||||
await program(home, {
|
||||
projectionApplier: async (): Promise<never> => {
|
||||
throw new Error('injected projection failure');
|
||||
},
|
||||
}).parseAsync(['node', 'mosaic', 'fleet', 'delete', 'coder0', '--expected-generation', '8']);
|
||||
|
||||
expect(JSON.parse(captured.lines.pop() ?? '')).toMatchObject({
|
||||
applied: false,
|
||||
authoritativeRoster: 'committed',
|
||||
projections: 'incomplete',
|
||||
recovery: { code: 'projection-apply-failed', action: 'regenerate-projections-from-roster' },
|
||||
});
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(await readFile(rosterPath, 'utf8')).toContain('generation: 9');
|
||||
expect(await readFile(rosterPath, 'utf8')).not.toContain('name: coder0');
|
||||
expect(await readFile(join(agentsDir, 'coder0.env.generated'), 'utf8')).toContain(
|
||||
'MOSAIC_AGENT_NAME=coder0',
|
||||
);
|
||||
});
|
||||
|
||||
it.each(['unsafe directory permissions', 'symlinked directory'] as const)(
|
||||
'rejects delete before roster mutation for %s',
|
||||
async (hazard: 'unsafe directory permissions' | 'symlinked directory'): Promise<void> => {
|
||||
const home = await fleetHome();
|
||||
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
||||
const agentsDir = join(home, 'fleet', 'agents');
|
||||
const captured = output();
|
||||
await program(home).parseAsync([
|
||||
'node',
|
||||
'mosaic',
|
||||
'fleet',
|
||||
'create',
|
||||
'--expected-generation',
|
||||
'7',
|
||||
'--agent',
|
||||
JSON.stringify(agent),
|
||||
]);
|
||||
const beforeRoster = await readFile(rosterPath, 'utf8');
|
||||
let generatedPath = join(agentsDir, 'coder0.env.generated');
|
||||
|
||||
if (hazard === 'unsafe directory permissions') {
|
||||
await chmod(agentsDir, 0o777);
|
||||
} else {
|
||||
const targetDir = join(home, 'fleet', 'agents-target');
|
||||
await rename(agentsDir, targetDir);
|
||||
await symlink(targetDir, agentsDir, 'dir');
|
||||
generatedPath = join(targetDir, 'coder0.env.generated');
|
||||
}
|
||||
|
||||
try {
|
||||
captured.lines.length = 0;
|
||||
await program(home).parseAsync([
|
||||
'node',
|
||||
'mosaic',
|
||||
'fleet',
|
||||
'delete',
|
||||
'coder0',
|
||||
'--expected-generation',
|
||||
'8',
|
||||
]);
|
||||
} finally {
|
||||
if (hazard === 'unsafe directory permissions') await chmod(agentsDir, 0o700);
|
||||
}
|
||||
|
||||
expect(JSON.parse(captured.lines.pop() ?? '')).toEqual({
|
||||
error: { code: 'mutation-failed' },
|
||||
});
|
||||
expect(await readFile(rosterPath, 'utf8')).toBe(beforeRoster);
|
||||
expect(await readFile(generatedPath, 'utf8')).toContain('MOSAIC_AGENT_NAME=coder0');
|
||||
},
|
||||
);
|
||||
|
||||
it('creates, updates, and deletes through the v2 roster without runtime actions', async (): Promise<void> => {
|
||||
const home = await fleetHome();
|
||||
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
||||
const agentsDir = join(home, 'fleet', 'agents');
|
||||
const captured = output();
|
||||
const updated = { ...agent, alias: 'Coder Zero' };
|
||||
|
||||
await program(home).parseAsync([
|
||||
'node',
|
||||
'mosaic',
|
||||
'fleet',
|
||||
'create',
|
||||
'--expected-generation',
|
||||
'7',
|
||||
'--agent',
|
||||
JSON.stringify(agent),
|
||||
]);
|
||||
expect(JSON.parse(captured.lines.pop() ?? '')).toMatchObject({ applied: true });
|
||||
expect(await readFile(join(agentsDir, 'coder0.env.generated'), 'utf8')).toContain(
|
||||
'MOSAIC_AGENT_NAME=coder0',
|
||||
);
|
||||
|
||||
await program(home).parseAsync([
|
||||
'node',
|
||||
'mosaic',
|
||||
'fleet',
|
||||
'update',
|
||||
'coder0',
|
||||
'--expected-generation',
|
||||
'8',
|
||||
'--agent',
|
||||
JSON.stringify(updated),
|
||||
]);
|
||||
expect(JSON.parse(captured.lines.pop() ?? '')).toMatchObject({
|
||||
applied: true,
|
||||
plan: { nextGeneration: 9, agent: { alias: 'Coder Zero' } },
|
||||
});
|
||||
|
||||
await writeFile(join(agentsDir, 'coder0.env.local'), 'MOSAIC_RUNTIME_BIN=/usr/bin/retain\n', {
|
||||
mode: 0o600,
|
||||
});
|
||||
await program(home).parseAsync([
|
||||
'node',
|
||||
'mosaic',
|
||||
'fleet',
|
||||
'delete',
|
||||
'coder0',
|
||||
'--expected-generation',
|
||||
'9',
|
||||
]);
|
||||
|
||||
expect(JSON.parse(captured.lines.pop() ?? '')).toMatchObject({
|
||||
applied: true,
|
||||
plan: { operation: 'delete', nextGeneration: 10, agent: { name: 'coder0' } },
|
||||
});
|
||||
await expect(readFile(join(agentsDir, 'coder0.env.generated'), 'utf8')).rejects.toThrow();
|
||||
expect(await readFile(join(agentsDir, 'coder0.env.local'), 'utf8')).toBe(
|
||||
'MOSAIC_RUNTIME_BIN=/usr/bin/retain\n',
|
||||
);
|
||||
expect(await readFile(rosterPath, 'utf8')).toContain('generation: 10');
|
||||
expect(await readFile(rosterPath, 'utf8')).not.toContain('name: coder0');
|
||||
});
|
||||
});
|
||||
@@ -1,392 +0,0 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { join, resolve } from 'node:path';
|
||||
import type { Command } from 'commander';
|
||||
import {
|
||||
executeFleetAgentMutation,
|
||||
FleetAgentMutationError,
|
||||
type FleetAgentMutationAgent,
|
||||
type FleetAgentMutationOperation,
|
||||
type FleetAgentMutationOptions,
|
||||
type FleetAgentMutationRequest,
|
||||
type FleetAgentMutationResult,
|
||||
} from '../fleet/fleet-agent-crud.js';
|
||||
import {
|
||||
parseRosterV2,
|
||||
ROSTER_V2_REASONING_LEVELS,
|
||||
ROSTER_V2_SUPPORTED_RUNTIMES,
|
||||
type FleetRosterV2,
|
||||
type RosterV2ReasoningLevel,
|
||||
type RosterV2RuntimeName,
|
||||
} from '../fleet/roster-v2.js';
|
||||
|
||||
export interface FleetAgentCrudCommandDeps {
|
||||
readonly mosaicHome?: string;
|
||||
/** Test seam for deterministic post-roster filesystem projection failures. */
|
||||
readonly projectionApplier?: FleetAgentMutationOptions['projectionApplier'];
|
||||
}
|
||||
|
||||
interface MutationOptions {
|
||||
readonly expectedGeneration: string;
|
||||
readonly agent?: string;
|
||||
readonly dryRun?: boolean;
|
||||
readonly persistedStart?: boolean;
|
||||
}
|
||||
|
||||
const AGENT_REQUEST_KEYS = new Set([
|
||||
'name',
|
||||
'alias',
|
||||
'className',
|
||||
'runtime',
|
||||
'provider',
|
||||
'model',
|
||||
'reasoning',
|
||||
'toolPolicy',
|
||||
'workingDirectory',
|
||||
'persistentPersona',
|
||||
'resetBetweenTasks',
|
||||
'launch',
|
||||
]);
|
||||
const AGENT_LAUNCH_REQUEST_KEYS = new Set(['yolo']);
|
||||
|
||||
/** Registers only roster-v2 desired-state mutations; it never invokes runtime actions. */
|
||||
export function registerFleetAgentCrudCommands(
|
||||
agentCommand: Command,
|
||||
deps: FleetAgentCrudCommandDeps = {},
|
||||
): void {
|
||||
agentCommand
|
||||
.command('get <name>')
|
||||
.description('Read one local roster-v2 agent as JSON')
|
||||
.action(async (name: string): Promise<void> => {
|
||||
await writeJsonOutcome(async (): Promise<void> => {
|
||||
const roster = await loadRoster(agentCommand, deps);
|
||||
const agent = roster.agents.find((candidate): boolean => candidate.name === name);
|
||||
if (!agent)
|
||||
throw new FleetAgentMutationError('agent-not-found', `Agent "${name}" is not in roster.`);
|
||||
printJson({ generation: roster.generation, agent });
|
||||
});
|
||||
});
|
||||
|
||||
registerMutationCommand(agentCommand, deps, 'create');
|
||||
registerMutationCommand(agentCommand, deps, 'update');
|
||||
registerMutationCommand(agentCommand, deps, 'delete');
|
||||
|
||||
agentCommand
|
||||
.command('plan <operation> [name]')
|
||||
.description('Plan a local roster-v2 mutation without writing roster or projections')
|
||||
.requiredOption('--expected-generation <number>', 'Authoritative roster generation')
|
||||
.option('--agent <json>', 'JSON agent payload for create or update')
|
||||
.option(
|
||||
'--persisted-start',
|
||||
'Plan create with desired_state: running without starting a runtime',
|
||||
)
|
||||
.action(
|
||||
async (operation: string, name: string | undefined, opts: MutationOptions): Promise<void> => {
|
||||
await writeJsonOutcome(async (): Promise<void> => {
|
||||
const parsedOperation = parseOperation(operation);
|
||||
await executeCommand(
|
||||
agentCommand,
|
||||
deps,
|
||||
parsedOperation,
|
||||
opts,
|
||||
true,
|
||||
parsePlanTargetName(parsedOperation, name),
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function registerMutationCommand(
|
||||
agentCommand: Command,
|
||||
deps: FleetAgentCrudCommandDeps,
|
||||
operation: FleetAgentMutationOperation,
|
||||
): void {
|
||||
const command = agentCommand
|
||||
.command(
|
||||
operation === 'update'
|
||||
? 'update <name>'
|
||||
: `${operation}${operation === 'delete' ? ' <name>' : ''}`,
|
||||
)
|
||||
.description(`${operation} a local roster-v2 agent without runtime actions`)
|
||||
.requiredOption('--expected-generation <number>', 'Authoritative roster generation')
|
||||
.option('--agent <json>', 'JSON agent payload for create or update')
|
||||
.option('--dry-run', 'Validate and plan without writing roster or projections');
|
||||
|
||||
if (operation === 'create') {
|
||||
command.option(
|
||||
'--persisted-start',
|
||||
'Persist desired_state: running without starting a runtime',
|
||||
);
|
||||
command.action(async (opts: MutationOptions): Promise<void> => {
|
||||
await writeJsonOutcome(async (): Promise<void> => {
|
||||
await executeCommand(agentCommand, deps, operation, opts, false);
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
command.action(async (name: string, opts: MutationOptions): Promise<void> => {
|
||||
await writeJsonOutcome(async (): Promise<void> => {
|
||||
await executeCommand(agentCommand, deps, operation, opts, false, name);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function executeCommand(
|
||||
agentCommand: Command,
|
||||
deps: FleetAgentCrudCommandDeps,
|
||||
operation: FleetAgentMutationOperation,
|
||||
opts: MutationOptions,
|
||||
forceDryRun: boolean,
|
||||
name?: string,
|
||||
): Promise<void> {
|
||||
const mosaicHome = resolveMosaicHome(agentCommand, deps);
|
||||
const rosterPath = resolveRosterPath(agentCommand, mosaicHome);
|
||||
const roster = parseRosterV2(await readFile(rosterPath, 'utf8'), 'yaml');
|
||||
const request = buildRequest(operation, opts, name);
|
||||
const result = await executeFleetAgentMutation({
|
||||
roster,
|
||||
request,
|
||||
mosaicHome,
|
||||
rosterPath,
|
||||
agentEnvDir: join(mosaicHome, 'fleet', 'agents'),
|
||||
rolesDir: join(mosaicHome, 'fleet', 'roles'),
|
||||
overrideDir: join(mosaicHome, 'fleet', 'roles.local'),
|
||||
dryRun: forceDryRun || opts.dryRun === true,
|
||||
...(deps.projectionApplier === undefined ? {} : { projectionApplier: deps.projectionApplier }),
|
||||
});
|
||||
printJson(result);
|
||||
process.exitCode = fleetAgentMutationExitCode(result);
|
||||
}
|
||||
|
||||
/** A persisted roster with failed derived projections is a non-zero CLI outcome. */
|
||||
export function fleetAgentMutationExitCode(
|
||||
result: Pick<FleetAgentMutationResult, 'recovery'>,
|
||||
): 0 | 1 {
|
||||
return result.recovery === undefined ? 0 : 1;
|
||||
}
|
||||
|
||||
function parsePlanTargetName(
|
||||
operation: FleetAgentMutationOperation,
|
||||
name: string | undefined,
|
||||
): string | undefined {
|
||||
if (operation === 'create') {
|
||||
if (name !== undefined) {
|
||||
throw new FleetAgentMutationError(
|
||||
'invalid-request',
|
||||
'Create plan does not accept a target name.',
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
if (name === undefined) {
|
||||
throw new FleetAgentMutationError(
|
||||
'invalid-request',
|
||||
`${operation} plan requires a target agent name.`,
|
||||
);
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
function buildRequest(
|
||||
operation: FleetAgentMutationOperation,
|
||||
opts: MutationOptions,
|
||||
name?: string,
|
||||
): FleetAgentMutationRequest {
|
||||
const expectedGeneration = parseExpectedGeneration(opts.expectedGeneration);
|
||||
const agent = opts.agent === undefined ? undefined : parseAgent(opts.agent);
|
||||
if ((operation === 'create' || operation === 'update') && !agent) {
|
||||
throw new FleetAgentMutationError('invalid-request', `${operation} requires --agent JSON.`);
|
||||
}
|
||||
return {
|
||||
operation,
|
||||
expectedGeneration,
|
||||
...(name === undefined ? {} : { name }),
|
||||
...(agent === undefined ? {} : { agent }),
|
||||
...(operation === 'create' && opts.persistedStart === true ? { persistedStart: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function parseExpectedGeneration(value: string): number {
|
||||
const generation = Number(value);
|
||||
if (!Number.isSafeInteger(generation) || generation < 1) {
|
||||
throw new FleetAgentMutationError(
|
||||
'invalid-request',
|
||||
'--expected-generation must be a positive safe integer.',
|
||||
);
|
||||
}
|
||||
return generation;
|
||||
}
|
||||
|
||||
function parseOperation(value: string): FleetAgentMutationOperation {
|
||||
if (value === 'create' || value === 'update' || value === 'delete') return value;
|
||||
throw new FleetAgentMutationError(
|
||||
'invalid-request',
|
||||
'plan operation must be create, update, or delete.',
|
||||
);
|
||||
}
|
||||
|
||||
function parseAgent(source: string): FleetAgentMutationAgent {
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(source);
|
||||
} catch {
|
||||
throw new FleetAgentMutationError('invalid-request', '--agent must be valid JSON.');
|
||||
}
|
||||
if (!isRecord(value)) {
|
||||
throw new FleetAgentMutationError('invalid-request', '--agent must be a JSON object.');
|
||||
}
|
||||
rejectUnknownKeys(value, AGENT_REQUEST_KEYS, '--agent');
|
||||
|
||||
const runtime = requiredRuntime(value, 'runtime');
|
||||
const reasoning = requiredReasoning(value, 'reasoning');
|
||||
const launch = requiredRecord(value, 'launch');
|
||||
rejectUnknownKeys(launch, AGENT_LAUNCH_REQUEST_KEYS, '--agent.launch');
|
||||
return {
|
||||
name: requiredString(value, 'name'),
|
||||
alias: requiredString(value, 'alias'),
|
||||
className: requiredString(value, 'className'),
|
||||
runtime,
|
||||
provider: requiredString(value, 'provider'),
|
||||
model: requiredString(value, 'model'),
|
||||
reasoning,
|
||||
toolPolicy: requiredString(value, 'toolPolicy'),
|
||||
workingDirectory: requiredString(value, 'workingDirectory'),
|
||||
persistentPersona: requiredBoolean(value, 'persistentPersona'),
|
||||
resetBetweenTasks: requiredBoolean(value, 'resetBetweenTasks'),
|
||||
launch: { yolo: requiredBoolean(launch, 'yolo') },
|
||||
};
|
||||
}
|
||||
|
||||
async function loadRoster(
|
||||
agentCommand: Command,
|
||||
deps: FleetAgentCrudCommandDeps,
|
||||
): Promise<FleetRosterV2> {
|
||||
const mosaicHome = resolveMosaicHome(agentCommand, deps);
|
||||
const rosterPath = resolveRosterPath(agentCommand, mosaicHome);
|
||||
return parseRosterV2(await readFile(rosterPath, 'utf8'), 'yaml');
|
||||
}
|
||||
|
||||
function resolveMosaicHome(agentCommand: Command, deps: FleetAgentCrudCommandDeps): string {
|
||||
const opts = agentCommand.optsWithGlobals<{ mosaicHome?: string }>();
|
||||
return opts.mosaicHome ?? deps.mosaicHome ?? join(process.env['HOME'] ?? '', '.config', 'mosaic');
|
||||
}
|
||||
|
||||
function resolveRosterPath(agentCommand: Command, mosaicHome: string): string {
|
||||
const opts = agentCommand.optsWithGlobals<{ roster?: string }>();
|
||||
const canonical = join(mosaicHome, 'fleet', 'roster.yaml');
|
||||
if (opts.roster !== undefined && resolve(opts.roster) !== resolve(canonical)) {
|
||||
throw new FleetAgentMutationError(
|
||||
'invalid-request',
|
||||
'Roster-v2 mutations require the canonical <mosaic-home>/fleet/roster.yaml path.',
|
||||
);
|
||||
}
|
||||
return canonical;
|
||||
}
|
||||
|
||||
function rejectUnknownKeys(
|
||||
value: Record<string, unknown>,
|
||||
allowedKeys: ReadonlySet<string>,
|
||||
field: string,
|
||||
): void {
|
||||
const hasUnsupportedOwnProperty = Object.getOwnPropertyNames(value).some(
|
||||
(key: string): boolean => !allowedKeys.has(key),
|
||||
);
|
||||
if (hasUnsupportedOwnProperty || Object.getOwnPropertySymbols(value).length > 0) {
|
||||
throw new FleetAgentMutationError('invalid-request', `${field} contains unsupported keys.`);
|
||||
}
|
||||
}
|
||||
|
||||
function requiredString(value: Record<string, unknown>, key: string): string {
|
||||
const candidate = requiredOwnValue(value, key);
|
||||
if (typeof candidate !== 'string' || candidate.length === 0) {
|
||||
throw new FleetAgentMutationError(
|
||||
'invalid-request',
|
||||
`--agent.${key} must be a non-empty string.`,
|
||||
);
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function requiredBoolean(value: Record<string, unknown>, key: string): boolean {
|
||||
const candidate = requiredOwnValue(value, key);
|
||||
if (typeof candidate !== 'boolean') {
|
||||
throw new FleetAgentMutationError('invalid-request', `--agent.${key} must be a boolean.`);
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function requiredRecord(value: Record<string, unknown>, key: string): Record<string, unknown> {
|
||||
const candidate = requiredOwnValue(value, key);
|
||||
if (!isRecord(candidate)) {
|
||||
throw new FleetAgentMutationError('invalid-request', `--agent.${key} must be an object.`);
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function requiredRuntime(value: Record<string, unknown>, key: string): RosterV2RuntimeName {
|
||||
const candidate = requiredString(value, key);
|
||||
if (!isRuntime(candidate)) {
|
||||
throw new FleetAgentMutationError(
|
||||
'invalid-request',
|
||||
`--agent.${key} is not a supported runtime.`,
|
||||
);
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function requiredReasoning(value: Record<string, unknown>, key: string): RosterV2ReasoningLevel {
|
||||
const candidate = requiredString(value, key);
|
||||
if (!isReasoning(candidate)) {
|
||||
throw new FleetAgentMutationError(
|
||||
'invalid-request',
|
||||
`--agent.${key} is not a supported reasoning level.`,
|
||||
);
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function isRuntime(value: string): value is RosterV2RuntimeName {
|
||||
return ROSTER_V2_SUPPORTED_RUNTIMES.some(
|
||||
(runtime: RosterV2RuntimeName): boolean => runtime === value,
|
||||
);
|
||||
}
|
||||
|
||||
function isReasoning(value: string): value is RosterV2ReasoningLevel {
|
||||
return ROSTER_V2_REASONING_LEVELS.some(
|
||||
(reasoning: RosterV2ReasoningLevel): boolean => reasoning === value,
|
||||
);
|
||||
}
|
||||
|
||||
function requiredOwnValue(value: Record<string, unknown>, key: string): unknown {
|
||||
if (!Object.hasOwn(value, key)) {
|
||||
throw new FleetAgentMutationError('invalid-request', `--agent.${key} is required.`);
|
||||
}
|
||||
return value[key];
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
!Array.isArray(value) &&
|
||||
Object.getPrototypeOf(value) === Object.prototype
|
||||
);
|
||||
}
|
||||
|
||||
async function writeJsonOutcome(action: () => Promise<void>): Promise<void> {
|
||||
try {
|
||||
await action();
|
||||
} catch (error: unknown) {
|
||||
process.exitCode = 1;
|
||||
printJson({
|
||||
error: {
|
||||
code: error instanceof FleetAgentMutationError ? error.code : 'mutation-failed',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function printJson(value: object): void {
|
||||
console.log(JSON.stringify(value));
|
||||
}
|
||||
@@ -1,18 +1,13 @@
|
||||
import { cp, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises';
|
||||
import { cp, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join, relative, resolve } from 'node:path';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
authorityForCanonicalClass,
|
||||
canonicalizeRoleClass,
|
||||
extractClassesFromDir,
|
||||
extractClassesFromDirSync,
|
||||
listPersonaClasses,
|
||||
personaStatus,
|
||||
resolvePersona,
|
||||
resolvePersonaFrom,
|
||||
resolvePersonaSync,
|
||||
} from './fleet-personas.js';
|
||||
import { loadProfiles, validateProfile, type FleetProfile } from './fleet-profiles.js';
|
||||
|
||||
@@ -59,156 +54,13 @@ afterEach(async () => {
|
||||
await rm(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('FCM class canonicalization and authority', () => {
|
||||
it.each([
|
||||
['implementer', 'code'],
|
||||
['reviewer', 'review'],
|
||||
['operator-interaction', 'interaction'],
|
||||
])('canonicalizes the approved alias %s to %s', (requested: string, canonical: string) => {
|
||||
expect(canonicalizeRoleClass(requested)).toEqual({
|
||||
requestedClass: requested,
|
||||
canonicalClass: canonical,
|
||||
});
|
||||
});
|
||||
|
||||
it.each(['worker', 'analyst', 'canary', 'tess', 'ultron', 'constructor'])(
|
||||
'does not infer an alias for %s',
|
||||
(requested: string) => {
|
||||
expect(canonicalizeRoleClass(requested)).toEqual({
|
||||
requestedClass: requested,
|
||||
canonicalClass: requested,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('resolves inherited-property class names without granting protected authority', async () => {
|
||||
await mkdir(overrideDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(overrideDir, 'constructor.md'),
|
||||
overridePersona('constructor', 'custom'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const resolved = await resolvePersona('constructor', { rolesDir, overrideDir });
|
||||
expect(resolved).toMatchObject({
|
||||
requestedClass: 'constructor',
|
||||
canonicalClass: 'constructor',
|
||||
klass: 'constructor',
|
||||
layer: 'override',
|
||||
});
|
||||
expect(authorityForCanonicalClass('constructor')).toMatchObject({
|
||||
mayMerge: false,
|
||||
mayIssueValidationCertificate: false,
|
||||
mayOrchestrate: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('canonicalizes before override lookup and lets the canonical override win', async () => {
|
||||
await mkdir(overrideDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(overrideDir, 'implementer.md'),
|
||||
overridePersona('implementer', 'legacy'),
|
||||
'utf8',
|
||||
);
|
||||
await writeFile(
|
||||
join(overrideDir, 'code.md'),
|
||||
overridePersona('code', 'engineering', 'CANONICAL-OVERRIDE'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const resolved = await resolvePersona('implementer', { rolesDir, overrideDir });
|
||||
expect(resolved).toMatchObject({
|
||||
requestedClass: 'implementer',
|
||||
canonicalClass: 'code',
|
||||
klass: 'code',
|
||||
layer: 'override',
|
||||
});
|
||||
expect(resolved?.content).toContain('CANONICAL-OVERRIDE');
|
||||
expect(resolved?.content).not.toContain('legacy');
|
||||
});
|
||||
|
||||
it('keeps sync and async resolution equivalent for aliases and canonical overrides', async () => {
|
||||
await mkdir(overrideDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(overrideDir, 'code.md'),
|
||||
overridePersona('code', 'engineering', 'CANONICAL-OVERRIDE'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const asyncResolution = await resolvePersona('implementer', { rolesDir, overrideDir });
|
||||
const syncResolution = resolvePersonaSync('implementer', { rolesDir, overrideDir });
|
||||
expect(syncResolution).toEqual(asyncResolution);
|
||||
});
|
||||
|
||||
it('derives immutable protected authority only from the canonical class', () => {
|
||||
expect(authorityForCanonicalClass('merge-gate')).toMatchObject({ mayMerge: true });
|
||||
for (const klass of [
|
||||
'validator',
|
||||
'orchestrator',
|
||||
'team-leader',
|
||||
'interaction',
|
||||
'code',
|
||||
'custom-role',
|
||||
]) {
|
||||
expect(authorityForCanonicalClass(klass).mayMerge).toBe(false);
|
||||
}
|
||||
expect(authorityForCanonicalClass('validator')).toMatchObject({
|
||||
mayIssueValidationCertificate: true,
|
||||
mayMerge: false,
|
||||
});
|
||||
expect(authorityForCanonicalClass('orchestrator')).toMatchObject({
|
||||
mayOrchestrate: true,
|
||||
mayIssueLeases: true,
|
||||
mayMerge: false,
|
||||
});
|
||||
expect(authorityForCanonicalClass('team-leader')).toMatchObject({
|
||||
leasedCapacityOnly: true,
|
||||
mayMutateRoster: false,
|
||||
mayAccessCredentials: false,
|
||||
mayMerge: false,
|
||||
});
|
||||
expect(authorityForCanonicalClass('interaction')).toMatchObject({
|
||||
requestStatusOnly: true,
|
||||
mayOrchestrate: false,
|
||||
mayMerge: false,
|
||||
});
|
||||
expect(Object.isFrozen(authorityForCanonicalClass('merge-gate'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('required FCM role library', () => {
|
||||
it.each([
|
||||
'code',
|
||||
'review',
|
||||
'validator',
|
||||
'orchestrator',
|
||||
'team-leader',
|
||||
'enhancer',
|
||||
'interaction',
|
||||
'merge-gate',
|
||||
])('resolves %s through the real framework role library', async (klass: string) => {
|
||||
const resolved = await resolvePersona(klass, {
|
||||
rolesDir: realRolesDir,
|
||||
overrideDir: join(tmp, 'none'),
|
||||
});
|
||||
expect(resolved).not.toBeNull();
|
||||
expect(resolved?.canonicalClass).toBe(klass);
|
||||
expect(resolved?.content.trim()).not.toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractClassesFromDir (shared extraction)', () => {
|
||||
it('records class + domain and distinguishes scanned from missing directories', async () => {
|
||||
it('records class + domain from inline markers and degrades on missing dir', async () => {
|
||||
const base = await extractClassesFromDir(rolesDir);
|
||||
expect(base.scanState).toBe('scanned');
|
||||
expect(base.classes.has('ceo')).toBe(true);
|
||||
expect(base.byClass.get('ceo')?.domain).toBe('executive');
|
||||
|
||||
const missingDir = join(tmp, 'nope');
|
||||
const missing = await extractClassesFromDir(missingDir);
|
||||
expect(missing.scanState).toBe('missing');
|
||||
const missing = await extractClassesFromDir(join(tmp, 'nope'));
|
||||
expect(missing.classes.size).toBe(0);
|
||||
expect(extractClassesFromDirSync(missingDir).scanState).toBe('missing');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -229,287 +81,6 @@ describe('resolvePersona — override wins', () => {
|
||||
expect(resolved?.content).toContain('BASELINE');
|
||||
});
|
||||
|
||||
it('does not let a LIBRARY-only row shadow a readable baseline persona', async () => {
|
||||
await mkdir(overrideDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(overrideDir, 'LIBRARY.md'),
|
||||
'| Persona | Purpose |\n| --- | --- |\n| code | Index only |\n',
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const resolved = await resolvePersona('code', { rolesDir, overrideDir });
|
||||
expect(resolved?.layer).toBe('baseline');
|
||||
expect(resolved?.content).toContain('BASELINE');
|
||||
expect((await listPersonaClasses({ rolesDir, overrideDir })).has('code')).toBe(true);
|
||||
expect(
|
||||
(await personaStatus({ rolesDir, overrideDir })).find(({ klass }) => klass === 'code')
|
||||
?.status,
|
||||
).toBe('baseline');
|
||||
});
|
||||
|
||||
it('does not let an incidental later class marker shadow a readable baseline persona', async () => {
|
||||
await mkdir(overrideDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(overrideDir, 'mascot.md'),
|
||||
'# mascot\n\n(`class: mascot`)\n\nSee also (`class: code`).\n',
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const resolved = await resolvePersona('code', { rolesDir, overrideDir });
|
||||
expect(resolved?.layer).toBe('baseline');
|
||||
expect(resolved?.content).toContain('BASELINE');
|
||||
expect((await listPersonaClasses({ rolesDir, overrideDir })).has('code')).toBe(true);
|
||||
expect((await listPersonaClasses({ rolesDir, overrideDir })).has('mascot')).toBe(true);
|
||||
expect(
|
||||
(await personaStatus({ rolesDir, overrideDir })).find(({ klass }) => klass === 'code')
|
||||
?.status,
|
||||
).toBe('baseline');
|
||||
});
|
||||
|
||||
it('does not advertise an incidental-only class through listing or status APIs', async () => {
|
||||
await mkdir(overrideDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(overrideDir, 'mascot.md'),
|
||||
'# mascot\n\n(`class: mascot`)\n\nSee also (`class: phantom`).\n',
|
||||
'utf8',
|
||||
);
|
||||
|
||||
expect((await listPersonaClasses({ rolesDir, overrideDir })).has('phantom')).toBe(false);
|
||||
expect(
|
||||
(await personaStatus({ rolesDir, overrideDir })).some(({ klass }) => klass === 'phantom'),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a canonical filename whose explicit marker names another class', async () => {
|
||||
await mkdir(overrideDir, { recursive: true });
|
||||
await writeFile(join(overrideDir, 'merge-gate.md'), overridePersona('mascot', 'fun'), 'utf8');
|
||||
await writeFile(
|
||||
join(rolesDir, 'merge-gate.md'),
|
||||
baselinePersona('merge-gate', 'governance'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
expect(await resolvePersona('merge-gate', { rolesDir, overrideDir })).toBeNull();
|
||||
expect(resolvePersonaSync('merge-gate', { rolesDir, overrideDir })).toBeNull();
|
||||
expect((await listPersonaClasses({ rolesDir, overrideDir })).has('merge-gate')).toBe(false);
|
||||
expect(
|
||||
(await personaStatus({ rolesDir, overrideDir })).some(({ klass }) => klass === 'merge-gate'),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('fails closed if a marker-defined override becomes unreadable after extraction', async () => {
|
||||
await mkdir(overrideDir, { recursive: true });
|
||||
const overrideFile = join(overrideDir, 'engineering.md');
|
||||
await writeFile(overrideFile, overridePersona('code', 'engineering'), 'utf8');
|
||||
const [base, over] = await Promise.all([
|
||||
extractClassesFromDir(rolesDir),
|
||||
extractClassesFromDir(overrideDir),
|
||||
]);
|
||||
await rm(overrideFile);
|
||||
await mkdir(overrideFile);
|
||||
|
||||
expect(await resolvePersonaFrom('code', { rolesDir, overrideDir, base, over })).toBeNull();
|
||||
});
|
||||
|
||||
it('fails closed if a marker-defined override changes identity after extraction', async () => {
|
||||
await mkdir(overrideDir, { recursive: true });
|
||||
const overrideFile = join(overrideDir, 'engineering.md');
|
||||
await writeFile(overrideFile, overridePersona('code', 'engineering'), 'utf8');
|
||||
const [base, over] = await Promise.all([
|
||||
extractClassesFromDir(rolesDir),
|
||||
extractClassesFromDir(overrideDir),
|
||||
]);
|
||||
await writeFile(overrideFile, overridePersona('mascot', 'fun'), 'utf8');
|
||||
|
||||
expect(await resolvePersonaFrom('code', { rolesDir, overrideDir, base, over })).toBeNull();
|
||||
|
||||
const classes = await listPersonaClasses({ rolesDir, overrideDir });
|
||||
expect(classes.has('code')).toBe(true);
|
||||
expect(classes.has('mascot')).toBe(true);
|
||||
const status = new Map(
|
||||
(await personaStatus({ rolesDir, overrideDir })).map((entry) => [entry.klass, entry]),
|
||||
);
|
||||
expect(status.get('code')?.status).toBe('baseline');
|
||||
expect(status.get('mascot')?.status).toBe('custom');
|
||||
});
|
||||
|
||||
it('fails closed if a markerless cached override gains a conflicting marker', async () => {
|
||||
await mkdir(overrideDir, { recursive: true });
|
||||
const overrideFile = join(overrideDir, 'merge-gate.md');
|
||||
await writeFile(overrideFile, '# markerless merge gate\n', 'utf8');
|
||||
await writeFile(
|
||||
join(rolesDir, 'merge-gate.md'),
|
||||
baselinePersona('merge-gate', 'governance'),
|
||||
'utf8',
|
||||
);
|
||||
const [base, over] = await Promise.all([
|
||||
extractClassesFromDir(rolesDir),
|
||||
extractClassesFromDir(overrideDir),
|
||||
]);
|
||||
await writeFile(overrideFile, overridePersona('mascot', 'fun'), 'utf8');
|
||||
|
||||
expect(
|
||||
await resolvePersonaFrom('merge-gate', { rolesDir, overrideDir, base, over }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('fails closed asynchronously when the override directory cannot be scanned', async () => {
|
||||
await writeFile(overrideDir, 'not a directory', 'utf8');
|
||||
expect(await resolvePersona('code', { rolesDir, overrideDir })).toBeNull();
|
||||
});
|
||||
|
||||
it('fails closed synchronously when the override directory cannot be scanned', async () => {
|
||||
await writeFile(overrideDir, 'not a directory', 'utf8');
|
||||
expect(resolvePersonaSync('code', { rolesDir, overrideDir })).toBeNull();
|
||||
});
|
||||
|
||||
it('fails closed asynchronously and synchronously for a dangling override-directory symlink', async () => {
|
||||
await symlink(join(tmp, 'missing-override-target'), overrideDir, 'dir');
|
||||
|
||||
expect(await resolvePersona('code', { rolesDir, overrideDir })).toBeNull();
|
||||
expect(resolvePersonaSync('code', { rolesDir, overrideDir })).toBeNull();
|
||||
});
|
||||
|
||||
it('fails closed asynchronously and synchronously when an override ancestor is a dangling symlink', async () => {
|
||||
const danglingAncestor = join(tmp, 'dangling-ancestor');
|
||||
await symlink(join(tmp, 'missing-ancestor-target'), danglingAncestor, 'dir');
|
||||
overrideDir = join(danglingAncestor, 'nested', 'roles.local');
|
||||
|
||||
expect(await resolvePersona('code', { rolesDir, overrideDir })).toBeNull();
|
||||
expect(resolvePersonaSync('code', { rolesDir, overrideDir })).toBeNull();
|
||||
expect(await listPersonaClasses({ rolesDir, overrideDir })).toEqual(new Set());
|
||||
expect(await personaStatus({ rolesDir, overrideDir })).toEqual([]);
|
||||
});
|
||||
|
||||
it('fails closed when dot-dot traversal crosses a dangling override ancestor', async () => {
|
||||
const danglingAncestor = join(tmp, 'dangling-dotdot-ancestor');
|
||||
await symlink(join(tmp, 'missing-dotdot-target'), danglingAncestor, 'dir');
|
||||
overrideDir = `${danglingAncestor}/../roles.local`;
|
||||
|
||||
expect(await resolvePersona('code', { rolesDir, overrideDir })).toBeNull();
|
||||
expect(resolvePersonaSync('code', { rolesDir, overrideDir })).toBeNull();
|
||||
expect(await listPersonaClasses({ rolesDir, overrideDir })).toEqual(new Set());
|
||||
expect(await personaStatus({ rolesDir, overrideDir })).toEqual([]);
|
||||
});
|
||||
|
||||
it('fails closed when relative dot-dot traversal crosses a dangling override ancestor', async () => {
|
||||
const danglingAncestor = join(tmp, 'relative-dangling-ancestor');
|
||||
await symlink(join(tmp, 'missing-relative-target'), danglingAncestor, 'dir');
|
||||
overrideDir = `${relative(process.cwd(), danglingAncestor)}/../roles.local`;
|
||||
expect(overrideDir.startsWith('/')).toBe(false);
|
||||
|
||||
expect(await resolvePersona('code', { rolesDir, overrideDir })).toBeNull();
|
||||
expect(resolvePersonaSync('code', { rolesDir, overrideDir })).toBeNull();
|
||||
expect(await listPersonaClasses({ rolesDir, overrideDir })).toEqual(new Set());
|
||||
expect(await personaStatus({ rolesDir, overrideDir })).toEqual([]);
|
||||
});
|
||||
|
||||
it('revalidates a cached missing override before baseline fallback', async () => {
|
||||
const cachedOverrideDir = join(tmp, 'mutable-ancestor', 'roles.local');
|
||||
const [base, over] = await Promise.all([
|
||||
extractClassesFromDir(rolesDir),
|
||||
extractClassesFromDir(cachedOverrideDir),
|
||||
]);
|
||||
expect(over.scanState).toBe('missing');
|
||||
await symlink(join(tmp, 'missing-mutable-target'), join(tmp, 'mutable-ancestor'), 'dir');
|
||||
|
||||
expect(
|
||||
await resolvePersonaFrom('code', {
|
||||
rolesDir,
|
||||
overrideDir: cachedOverrideDir,
|
||||
base,
|
||||
over,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('revalidates a cached scanned override before baseline fallback', async () => {
|
||||
await mkdir(overrideDir, { recursive: true });
|
||||
const [base, over] = await Promise.all([
|
||||
extractClassesFromDir(rolesDir),
|
||||
extractClassesFromDir(overrideDir),
|
||||
]);
|
||||
expect(over.scanState).toBe('scanned');
|
||||
expect(over.classes.size).toBe(0);
|
||||
|
||||
await writeFile(join(overrideDir, 'engineering.md'), overridePersona('code', 'engineering'));
|
||||
|
||||
const resolved = await resolvePersonaFrom('code', {
|
||||
rolesDir,
|
||||
overrideDir,
|
||||
base,
|
||||
over,
|
||||
});
|
||||
expect(resolved?.layer).toBe('override');
|
||||
expect(resolved?.file).toBe(join(overrideDir, 'engineering.md'));
|
||||
});
|
||||
|
||||
it('falls back to baseline asynchronously and synchronously when override directory is missing', async () => {
|
||||
const asyncResolution = await resolvePersona('code', { rolesDir, overrideDir });
|
||||
const syncResolution = resolvePersonaSync('code', { rolesDir, overrideDir });
|
||||
expect(asyncResolution?.layer).toBe('baseline');
|
||||
expect(syncResolution?.layer).toBe('baseline');
|
||||
});
|
||||
|
||||
it('fails closed asynchronously when the canonical override exists but is unreadable', async () => {
|
||||
await mkdir(overrideDir, { recursive: true });
|
||||
await mkdir(join(overrideDir, 'code.md'));
|
||||
|
||||
expect(await resolvePersona('code', { rolesDir, overrideDir })).toBeNull();
|
||||
});
|
||||
|
||||
it('does not advertise a shadowed baseline whose canonical override is unreadable', async () => {
|
||||
await mkdir(overrideDir, { recursive: true });
|
||||
await mkdir(join(overrideDir, 'code.md'));
|
||||
|
||||
expect((await listPersonaClasses({ rolesDir, overrideDir })).has('code')).toBe(false);
|
||||
expect(
|
||||
(await personaStatus({ rolesDir, overrideDir })).some(({ klass }) => klass === 'code'),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('does not advertise baseline personas when the override directory is unscannable', async () => {
|
||||
await writeFile(overrideDir, 'not a directory', 'utf8');
|
||||
|
||||
expect(await listPersonaClasses({ rolesDir, overrideDir })).toEqual(new Set());
|
||||
expect(await personaStatus({ rolesDir, overrideDir })).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not advertise baseline personas through a dangling override-directory symlink', async () => {
|
||||
await symlink(join(tmp, 'missing-override-target'), overrideDir, 'dir');
|
||||
|
||||
expect(await listPersonaClasses({ rolesDir, overrideDir })).toEqual(new Set());
|
||||
expect(await personaStatus({ rolesDir, overrideDir })).toEqual([]);
|
||||
});
|
||||
|
||||
it('preserves baseline listings when the override directory is missing', async () => {
|
||||
expect((await listPersonaClasses({ rolesDir, overrideDir })).has('code')).toBe(true);
|
||||
expect(
|
||||
(await personaStatus({ rolesDir, overrideDir })).find(({ klass }) => klass === 'code')
|
||||
?.status,
|
||||
).toBe('baseline');
|
||||
});
|
||||
|
||||
it('does not advertise an unreadable custom override as a valid persona class or status', async () => {
|
||||
await mkdir(overrideDir, { recursive: true });
|
||||
await mkdir(join(overrideDir, 'ghost.md'));
|
||||
|
||||
const extracted = await extractClassesFromDir(overrideDir);
|
||||
expect(extracted.fileStems.has('ghost')).toBe(true);
|
||||
expect(extracted.classes.has('ghost')).toBe(false);
|
||||
expect((await listPersonaClasses({ rolesDir, overrideDir })).has('ghost')).toBe(false);
|
||||
expect(
|
||||
(await personaStatus({ rolesDir, overrideDir })).some(({ klass }) => klass === 'ghost'),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('fails closed synchronously when the canonical override exists but is unreadable', async () => {
|
||||
await mkdir(overrideDir, { recursive: true });
|
||||
await mkdir(join(overrideDir, 'code.md'));
|
||||
|
||||
expect(resolvePersonaSync('code', { rolesDir, overrideDir })).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for an unknown class', async () => {
|
||||
expect(await resolvePersona('does-not-exist', { rolesDir, overrideDir })).toBeNull();
|
||||
});
|
||||
|
||||
@@ -25,10 +25,10 @@
|
||||
* can reference a customized or user-added persona.
|
||||
*/
|
||||
|
||||
import { lstatSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
||||
import { lstat, readFile, readdir, stat } from 'node:fs/promises';
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import { readFile, readdir } from 'node:fs/promises';
|
||||
import { homedir } from 'node:os';
|
||||
import { basename, isAbsolute, join, sep } from 'node:path';
|
||||
import { basename, join } from 'node:path';
|
||||
import type { Command } from 'commander';
|
||||
|
||||
function defaultMosaicHome(): string {
|
||||
@@ -53,136 +53,52 @@ export function defaultOverrideDir(mosaicHome = defaultMosaicHome()): string {
|
||||
const CLASS_MARKER = /`?class:\s*\n?\s*([a-z][a-z0-9-]*)`?/g;
|
||||
/** Optional `domain: Y` marker that travels alongside the class in the prose. */
|
||||
const DOMAIN_MARKER = /`?domain:\s*\n?\s*([a-z][a-z0-9-]*)`?/;
|
||||
/** LIBRARY.md persona rows: the first table cell is the persona name. */
|
||||
const LIBRARY_ROW = /^\|\s*([a-z][a-z0-9-]*)\s*\|/gm;
|
||||
|
||||
/** Where a resolved persona's definition came from. */
|
||||
export type PersonaLayer = 'baseline' | 'override';
|
||||
|
||||
export const ROLE_CLASS_ALIASES = Object.freeze({
|
||||
implementer: 'code',
|
||||
reviewer: 'review',
|
||||
'operator-interaction': 'interaction',
|
||||
} as const);
|
||||
|
||||
export interface CanonicalRoleClass {
|
||||
readonly requestedClass: string;
|
||||
readonly canonicalClass: string;
|
||||
}
|
||||
|
||||
/** Canonicalize only the three explicitly approved compatibility aliases. */
|
||||
export function canonicalizeRoleClass(requestedClass: string): CanonicalRoleClass {
|
||||
const requested = requestedClass.trim();
|
||||
const canonical = Object.hasOwn(ROLE_CLASS_ALIASES, requested)
|
||||
? ROLE_CLASS_ALIASES[requested as keyof typeof ROLE_CLASS_ALIASES]
|
||||
: requested;
|
||||
return Object.freeze({ requestedClass: requested, canonicalClass: canonical });
|
||||
}
|
||||
|
||||
export interface RoleAuthority {
|
||||
readonly mayMerge: boolean;
|
||||
readonly mayIssueValidationCertificate: boolean;
|
||||
readonly mayOrchestrate: boolean;
|
||||
readonly mayManageTopology: boolean;
|
||||
readonly mayIssueLeases: boolean;
|
||||
readonly leasedCapacityOnly: boolean;
|
||||
readonly requestStatusOnly: boolean;
|
||||
readonly mayMutateRoster: boolean;
|
||||
readonly mayMutateConfiguration: boolean;
|
||||
readonly mayAccessCredentials: boolean;
|
||||
}
|
||||
|
||||
const NO_PROTECTED_AUTHORITY: RoleAuthority = Object.freeze({
|
||||
mayMerge: false,
|
||||
mayIssueValidationCertificate: false,
|
||||
mayOrchestrate: false,
|
||||
mayManageTopology: false,
|
||||
mayIssueLeases: false,
|
||||
leasedCapacityOnly: false,
|
||||
requestStatusOnly: false,
|
||||
mayMutateRoster: false,
|
||||
mayMutateConfiguration: false,
|
||||
mayAccessCredentials: false,
|
||||
});
|
||||
|
||||
/** Immutable authority contracts keyed only by canonical class identity. */
|
||||
export const ROLE_AUTHORITY_BY_CANONICAL_CLASS: Readonly<Record<string, RoleAuthority>> =
|
||||
Object.freeze({
|
||||
'merge-gate': Object.freeze({ ...NO_PROTECTED_AUTHORITY, mayMerge: true }),
|
||||
validator: Object.freeze({
|
||||
...NO_PROTECTED_AUTHORITY,
|
||||
mayIssueValidationCertificate: true,
|
||||
}),
|
||||
orchestrator: Object.freeze({
|
||||
...NO_PROTECTED_AUTHORITY,
|
||||
mayOrchestrate: true,
|
||||
mayManageTopology: true,
|
||||
mayIssueLeases: true,
|
||||
}),
|
||||
'team-leader': Object.freeze({ ...NO_PROTECTED_AUTHORITY, leasedCapacityOnly: true }),
|
||||
interaction: Object.freeze({ ...NO_PROTECTED_AUTHORITY, requestStatusOnly: true }),
|
||||
});
|
||||
|
||||
/** Return protected authority for an already-canonical class; custom classes get none. */
|
||||
export function authorityForCanonicalClass(canonicalClass: string): RoleAuthority {
|
||||
return Object.hasOwn(ROLE_AUTHORITY_BY_CANONICAL_CLASS, canonicalClass)
|
||||
? ROLE_AUTHORITY_BY_CANONICAL_CLASS[canonicalClass]!
|
||||
: NO_PROTECTED_AUTHORITY;
|
||||
}
|
||||
|
||||
/** One discovered persona file (a single role contract on disk). */
|
||||
export interface PersonaFile {
|
||||
klass: string;
|
||||
/** The markdown file the class was found in. */
|
||||
file: string;
|
||||
/** True when the first class marker, rather than filename, defined this mapping. */
|
||||
markerDefined?: boolean;
|
||||
domain?: string;
|
||||
}
|
||||
|
||||
export type DirScanState = 'scanned' | 'missing' | 'error';
|
||||
|
||||
/** The set of persona classes a directory of role contracts defines. */
|
||||
export interface DirClasses {
|
||||
/** Whether the directory was scanned, absent, or present-but-unscannable. */
|
||||
scanState: DirScanState;
|
||||
/** Every readable class name the directory contributes by filename or first marker. */
|
||||
/** Every class name the dir contributes (markers + filenames + LIBRARY rows). */
|
||||
classes: Set<string>;
|
||||
/** Filename stems present on disk, retained even when a markdown entry is unreadable. */
|
||||
fileStems: Set<string>;
|
||||
/** For classes whose file carries a marker, the file + domain that defined it. */
|
||||
byClass: Map<string, PersonaFile>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan one directory of role contracts and extract readable persona identities.
|
||||
* Valid identities come only from a successfully read non-LIBRARY filename and
|
||||
* that file's first `class:` marker. LIBRARY rows and later prose mentions are
|
||||
* index/reference data, not independently readable role contracts.
|
||||
* Scan one directory of role contracts and extract the persona classes it
|
||||
* defines. THIS is the shared extraction both fleet-personas and fleet-profiles
|
||||
* rely on. Sources, unioned (each needed — see module doc):
|
||||
* 1. inline `class: X` markers in roles/*.md (primary; may wrap a newline),
|
||||
* 2. persona-name cells from LIBRARY.md index tables,
|
||||
* 3. the role filename stem (covers marker-less alias docs like planner).
|
||||
*
|
||||
* Missing directories are distinguished from present-but-unscannable paths.
|
||||
* `byClass` records the first marker-defined file+domain so the resolver can map
|
||||
* a class back to its contract; marker-less readable files map by filename.
|
||||
* Missing dir / unreadable files degrade gracefully to whatever was found.
|
||||
* `byClass` records the defining file+domain for marker-bearing classes so the
|
||||
* resolver can map a class back to its file; filename-only and LIBRARY-only
|
||||
* classes still appear in `classes` for membership checks.
|
||||
*/
|
||||
export async function extractClassesFromDir(dir: string): Promise<DirClasses> {
|
||||
const acc: DirClasses = {
|
||||
scanState: 'scanned',
|
||||
classes: new Set<string>(),
|
||||
fileStems: new Set<string>(),
|
||||
byClass: new Map<string, PersonaFile>(),
|
||||
};
|
||||
const acc: DirClasses = { classes: new Set<string>(), byClass: new Map<string, PersonaFile>() };
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = await readdir(dir);
|
||||
} catch (error) {
|
||||
acc.scanState = await classifyScanFailure(dir, error);
|
||||
} catch {
|
||||
return acc;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.endsWith('.md')) continue;
|
||||
// Preserve entry presence separately from valid readable classes. Resolvers
|
||||
// use it to fail closed on an explicit unreadable canonical override without
|
||||
// advertising that override through class listing/status APIs.
|
||||
if (entry !== 'LIBRARY.md') acc.fileStems.add(basename(entry, '.md'));
|
||||
let text: string;
|
||||
try {
|
||||
text = await readFile(join(dir, entry), 'utf8');
|
||||
@@ -201,25 +117,16 @@ export async function extractClassesFromDir(dir: string): Promise<DirClasses> {
|
||||
* cannot await. Missing dir / unreadable files degrade gracefully.
|
||||
*/
|
||||
export function extractClassesFromDirSync(dir: string): DirClasses {
|
||||
const acc: DirClasses = {
|
||||
scanState: 'scanned',
|
||||
classes: new Set<string>(),
|
||||
fileStems: new Set<string>(),
|
||||
byClass: new Map<string, PersonaFile>(),
|
||||
};
|
||||
const acc: DirClasses = { classes: new Set<string>(), byClass: new Map<string, PersonaFile>() };
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = readdirSync(dir);
|
||||
} catch (error) {
|
||||
acc.scanState = classifyScanFailureSync(dir, error);
|
||||
} catch {
|
||||
return acc;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.endsWith('.md')) continue;
|
||||
// Keep sync extraction equivalent to the async scanner, including unreadable
|
||||
// filename presence kept separate from valid readable classes.
|
||||
if (entry !== 'LIBRARY.md') acc.fileStems.add(basename(entry, '.md'));
|
||||
let text: string;
|
||||
try {
|
||||
text = readFileSync(join(dir, entry), 'utf8');
|
||||
@@ -231,74 +138,6 @@ export function extractClassesFromDirSync(dir: string): DirClasses {
|
||||
return acc;
|
||||
}
|
||||
|
||||
async function classifyScanFailure(dir: string, error: unknown): Promise<DirScanState> {
|
||||
if (!isMissingPathError(error)) return 'error';
|
||||
return (await hasBrokenSymlinkInPath(dir)) ? 'error' : 'missing';
|
||||
}
|
||||
|
||||
function classifyScanFailureSync(dir: string, error: unknown): DirScanState {
|
||||
if (!isMissingPathError(error)) return 'error';
|
||||
return hasBrokenSymlinkInPathSync(dir) ? 'error' : 'missing';
|
||||
}
|
||||
|
||||
function traversalPrefixes(path: string): string[] {
|
||||
const absolute = isAbsolute(path) ? path : `${process.cwd()}${sep}${path}`;
|
||||
const parts = absolute.split(sep);
|
||||
const prefixes: string[] = [];
|
||||
let current: string = sep;
|
||||
for (const part of parts) {
|
||||
if (!part) continue;
|
||||
current = current === sep ? `${sep}${part}` : `${current}${sep}${part}`;
|
||||
prefixes.push(current);
|
||||
}
|
||||
return prefixes;
|
||||
}
|
||||
|
||||
async function hasBrokenSymlinkInPath(path: string): Promise<boolean> {
|
||||
for (const current of traversalPrefixes(path)) {
|
||||
try {
|
||||
const entry = await lstat(current);
|
||||
if (entry.isSymbolicLink()) {
|
||||
try {
|
||||
await stat(current);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isMissingPathError(error)) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasBrokenSymlinkInPathSync(path: string): boolean {
|
||||
for (const current of traversalPrefixes(path)) {
|
||||
try {
|
||||
const entry = lstatSync(current);
|
||||
if (entry.isSymbolicLink()) {
|
||||
try {
|
||||
statSync(current);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isMissingPathError(error)) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isMissingPathError(error: unknown): boolean {
|
||||
return (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'code' in error &&
|
||||
(error as { code?: unknown }).code === 'ENOENT'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the class-extraction rules for ONE role file's text into `acc`. Pure
|
||||
* over already-read content, so the async and sync directory scanners share a
|
||||
@@ -307,26 +146,33 @@ function isMissingPathError(error: unknown): boolean {
|
||||
*/
|
||||
function accumulateEntry(acc: DirClasses, dir: string, entry: string, text: string): void {
|
||||
const { classes, byClass } = acc;
|
||||
if (entry === 'LIBRARY.md') return;
|
||||
|
||||
// An explicit first marker owns identity. Filename identity is a fallback only
|
||||
// for markerless compatibility contracts such as planner.md.
|
||||
if (entry === 'LIBRARY.md') {
|
||||
for (const m of text.matchAll(LIBRARY_ROW)) {
|
||||
const name = m[1];
|
||||
if (name && name !== 'persona') classes.add(name);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// The filename stem is itself a valid class (covers marker-less alias docs).
|
||||
const stem = basename(entry, '.md');
|
||||
classes.add(stem);
|
||||
const domainMatch = DOMAIN_MARKER.exec(text);
|
||||
const domain = domainMatch?.[1];
|
||||
const firstMarker = text.matchAll(CLASS_MARKER).next().value as RegExpExecArray | undefined;
|
||||
const markedClassForFile = firstMarker?.[1];
|
||||
if (markedClassForFile) {
|
||||
classes.add(markedClassForFile);
|
||||
byClass.set(markedClassForFile, {
|
||||
klass: markedClassForFile,
|
||||
file: join(dir, entry),
|
||||
markerDefined: true,
|
||||
...(domain ? { domain } : {}),
|
||||
});
|
||||
} else {
|
||||
classes.add(stem);
|
||||
if (!byClass.has(stem)) byClass.set(stem, { klass: stem, file: join(dir, entry) });
|
||||
let markedClassForFile: string | undefined;
|
||||
for (const m of text.matchAll(CLASS_MARKER)) {
|
||||
const klass = m[1];
|
||||
if (!klass) continue;
|
||||
classes.add(klass);
|
||||
// Record the FIRST marker as the file's defining class (the prose names
|
||||
// the persona's own class up top; later mentions reference siblings).
|
||||
if (!markedClassForFile) {
|
||||
markedClassForFile = klass;
|
||||
byClass.set(klass, { klass, file: join(dir, entry), ...(domain ? { domain } : {}) });
|
||||
}
|
||||
}
|
||||
// A marker-less file still maps its stem to itself (no domain known).
|
||||
if (!markedClassForFile && !byClass.has(stem)) {
|
||||
byClass.set(stem, { klass: stem, file: join(dir, entry) });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -347,19 +193,24 @@ function resolveDirs(opts: PersonaDirs): { rolesDir: string; overrideDir: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Every class with an actually readable winning contract. Discovery applies the
|
||||
* same override shadow/fail-closed semantics as resolution, so callers never
|
||||
* advertise a class that cannot be used.
|
||||
* UNION of baseline classes and override classes. Overrides may ADD entirely new
|
||||
* classes not present in the baseline, so callers (e.g. profile roster
|
||||
* validation) treat a user-added persona as a real class.
|
||||
*/
|
||||
export async function listPersonaClasses(opts: PersonaDirs = {}): Promise<Set<string>> {
|
||||
const { personas } = await collectUsablePersonas(opts);
|
||||
return new Set(personas.keys());
|
||||
const { rolesDir, overrideDir } = resolveDirs(opts);
|
||||
const [base, over] = await Promise.all([
|
||||
extractClassesFromDir(rolesDir),
|
||||
extractClassesFromDir(overrideDir),
|
||||
]);
|
||||
const union = new Set<string>(base.classes);
|
||||
for (const c of over.classes) union.add(c);
|
||||
return union;
|
||||
}
|
||||
|
||||
export type PersonaStatus = 'baseline' | 'overridden' | 'custom';
|
||||
|
||||
export interface PersonaResolution extends CanonicalRoleClass {
|
||||
/** Compatibility name for the canonical class. */
|
||||
export interface PersonaResolution {
|
||||
klass: string;
|
||||
layer: PersonaLayer;
|
||||
/** The file the resolved persona was read from (override wins). */
|
||||
@@ -368,118 +219,6 @@ export interface PersonaResolution extends CanonicalRoleClass {
|
||||
domain?: string;
|
||||
}
|
||||
|
||||
async function readPersonaFromLayer(
|
||||
requestedClass: string,
|
||||
canonicalClass: string,
|
||||
dir: string,
|
||||
extracted: DirClasses,
|
||||
layer: PersonaLayer,
|
||||
): Promise<PersonaResolution | null> {
|
||||
const pf = extracted.byClass.get(canonicalClass);
|
||||
if (!pf) {
|
||||
if (!extracted.classes.has(canonicalClass)) return null;
|
||||
const byName = join(dir, `${canonicalClass}.md`);
|
||||
try {
|
||||
const content = await readFile(byName, 'utf8');
|
||||
const currentMarker = content.matchAll(CLASS_MARKER).next().value as
|
||||
| RegExpExecArray
|
||||
| undefined;
|
||||
if (currentMarker && currentMarker[1] !== canonicalClass) return null;
|
||||
const dm = DOMAIN_MARKER.exec(content);
|
||||
return {
|
||||
requestedClass,
|
||||
canonicalClass,
|
||||
klass: canonicalClass,
|
||||
layer,
|
||||
file: byName,
|
||||
content,
|
||||
...(dm?.[1] ? { domain: dm[1] } : {}),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const content = await readFile(pf.file, 'utf8');
|
||||
const currentMarker = content.matchAll(CLASS_MARKER).next().value as
|
||||
| RegExpExecArray
|
||||
| undefined;
|
||||
if (currentMarker && currentMarker[1] !== canonicalClass) return null;
|
||||
const dm = DOMAIN_MARKER.exec(content);
|
||||
return {
|
||||
requestedClass,
|
||||
canonicalClass,
|
||||
klass: canonicalClass,
|
||||
layer,
|
||||
file: pf.file,
|
||||
content,
|
||||
...(dm?.[1] ? { domain: dm[1] } : {}),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function overrideShadowsBaseline(over: DirClasses, canonicalClass: string): boolean {
|
||||
return (
|
||||
over.scanState === 'error' ||
|
||||
over.fileStems.has(canonicalClass) ||
|
||||
over.byClass.has(canonicalClass)
|
||||
);
|
||||
}
|
||||
|
||||
function readPersonaFromLayerSync(
|
||||
requestedClass: string,
|
||||
canonicalClass: string,
|
||||
dir: string,
|
||||
extracted: DirClasses,
|
||||
layer: PersonaLayer,
|
||||
): PersonaResolution | null {
|
||||
const pf = extracted.byClass.get(canonicalClass);
|
||||
if (!pf) {
|
||||
if (!extracted.classes.has(canonicalClass)) return null;
|
||||
const byName = join(dir, `${canonicalClass}.md`);
|
||||
try {
|
||||
const content = readFileSync(byName, 'utf8');
|
||||
const currentMarker = content.matchAll(CLASS_MARKER).next().value as
|
||||
| RegExpExecArray
|
||||
| undefined;
|
||||
if (currentMarker && currentMarker[1] !== canonicalClass) return null;
|
||||
const dm = DOMAIN_MARKER.exec(content);
|
||||
return {
|
||||
requestedClass,
|
||||
canonicalClass,
|
||||
klass: canonicalClass,
|
||||
layer,
|
||||
file: byName,
|
||||
content,
|
||||
...(dm?.[1] ? { domain: dm[1] } : {}),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const content = readFileSync(pf.file, 'utf8');
|
||||
const currentMarker = content.matchAll(CLASS_MARKER).next().value as
|
||||
| RegExpExecArray
|
||||
| undefined;
|
||||
if (currentMarker && currentMarker[1] !== canonicalClass) return null;
|
||||
const dm = DOMAIN_MARKER.exec(content);
|
||||
return {
|
||||
requestedClass,
|
||||
canonicalClass,
|
||||
klass: canonicalClass,
|
||||
layer,
|
||||
file: pf.file,
|
||||
content,
|
||||
...(dm?.[1] ? { domain: dm[1] } : {}),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a persona class to its winning definition: the override file if
|
||||
* roles.local/ defines that class, else the baseline. Match by inline `class:`
|
||||
@@ -506,32 +245,42 @@ export async function resolvePersona(
|
||||
* is identical to {@link resolvePersona}: override layer wins, then baseline.
|
||||
*/
|
||||
export async function resolvePersonaFrom(
|
||||
requestedClass: string,
|
||||
klass: string,
|
||||
layers: { rolesDir: string; overrideDir: string; base: DirClasses; over: DirClasses },
|
||||
): Promise<PersonaResolution | null> {
|
||||
const { requestedClass: requested, canonicalClass: klass } =
|
||||
canonicalizeRoleClass(requestedClass);
|
||||
const { rolesDir, overrideDir, base, over } = layers;
|
||||
|
||||
const override = await readPersonaFromLayer(requested, klass, overrideDir, over, 'override');
|
||||
if (override) return override;
|
||||
// An observed explicit override that cannot resolve/read shadows the baseline.
|
||||
if (overrideShadowsBaseline(over, klass)) return null;
|
||||
const fromLayer = async (
|
||||
dir: string,
|
||||
extracted: DirClasses,
|
||||
layer: PersonaLayer,
|
||||
): Promise<PersonaResolution | null> => {
|
||||
// Prefer the marker-defined file; fall back to the filename stem.
|
||||
let pf = extracted.byClass.get(klass);
|
||||
if (!pf) {
|
||||
const byName = join(dir, `${klass}.md`);
|
||||
if (!extracted.classes.has(klass)) return null;
|
||||
// Class known only via filename/LIBRARY: read the stem file if present.
|
||||
try {
|
||||
const content = await readFile(byName, 'utf8');
|
||||
const dm = DOMAIN_MARKER.exec(content);
|
||||
return { klass, layer, file: byName, content, ...(dm?.[1] ? { domain: dm[1] } : {}) };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const content = await readFile(pf.file, 'utf8');
|
||||
return { klass, layer, file: pf.file, content, ...(pf.domain ? { domain: pf.domain } : {}) };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Cached scans are only snapshots. Re-scan immediately before baseline fallback
|
||||
// so a newly created canonical or marker-defined override cannot be skipped.
|
||||
const currentOver = await extractClassesFromDir(overrideDir);
|
||||
const currentOverride = await readPersonaFromLayer(
|
||||
requested,
|
||||
klass,
|
||||
overrideDir,
|
||||
currentOver,
|
||||
'override',
|
||||
return (
|
||||
(await fromLayer(overrideDir, over, 'override')) ??
|
||||
(await fromLayer(rolesDir, base, 'baseline'))
|
||||
);
|
||||
if (currentOverride) return currentOverride;
|
||||
if (overrideShadowsBaseline(currentOver, klass)) return null;
|
||||
if (currentOver.scanState === 'error') return null;
|
||||
return readPersonaFromLayer(requested, klass, rolesDir, base, 'baseline');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -543,55 +292,40 @@ export async function resolvePersonaFrom(
|
||||
* one module so the launch-time and command-time resolutions never diverge.
|
||||
*/
|
||||
export function resolvePersonaSync(
|
||||
requestedClass: string,
|
||||
klass: string,
|
||||
opts: PersonaDirs = {},
|
||||
): PersonaResolution | null {
|
||||
const { requestedClass: requested, canonicalClass: klass } =
|
||||
canonicalizeRoleClass(requestedClass);
|
||||
const { rolesDir, overrideDir } = resolveDirs(opts);
|
||||
const base = extractClassesFromDirSync(rolesDir);
|
||||
const over = extractClassesFromDirSync(overrideDir);
|
||||
|
||||
const override = readPersonaFromLayerSync(requested, klass, overrideDir, over, 'override');
|
||||
if (override) return override;
|
||||
if (overrideShadowsBaseline(over, klass)) return null;
|
||||
return readPersonaFromLayerSync(requested, klass, rolesDir, base, 'baseline');
|
||||
}
|
||||
const fromLayer = (
|
||||
dir: string,
|
||||
extracted: DirClasses,
|
||||
layer: PersonaLayer,
|
||||
): PersonaResolution | null => {
|
||||
// Prefer the marker-defined file; fall back to the filename stem.
|
||||
const pf = extracted.byClass.get(klass);
|
||||
if (!pf) {
|
||||
if (!extracted.classes.has(klass)) return null;
|
||||
const byName = join(dir, `${klass}.md`);
|
||||
try {
|
||||
const content = readFileSync(byName, 'utf8');
|
||||
const dm = DOMAIN_MARKER.exec(content);
|
||||
return { klass, layer, file: byName, content, ...(dm?.[1] ? { domain: dm[1] } : {}) };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const content = readFileSync(pf.file, 'utf8');
|
||||
return { klass, layer, file: pf.file, content, ...(pf.domain ? { domain: pf.domain } : {}) };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
interface UsablePersonaIndex {
|
||||
personas: Map<string, PersonaResolution>;
|
||||
readableBaseline: Set<string>;
|
||||
}
|
||||
|
||||
async function collectUsablePersonas(opts: PersonaDirs): Promise<UsablePersonaIndex> {
|
||||
const { rolesDir, overrideDir } = resolveDirs(opts);
|
||||
const [base, over] = await Promise.all([
|
||||
extractClassesFromDir(rolesDir),
|
||||
extractClassesFromDir(overrideDir),
|
||||
]);
|
||||
if (over.scanState === 'error') {
|
||||
return { personas: new Map(), readableBaseline: new Set() };
|
||||
}
|
||||
|
||||
const candidates = new Set<string>([...base.classes, ...over.classes]);
|
||||
const checked = await Promise.all(
|
||||
[...candidates].map(async (requestedClass) => {
|
||||
const { canonicalClass } = canonicalizeRoleClass(requestedClass);
|
||||
const [persona, baseline] = await Promise.all([
|
||||
resolvePersonaFrom(requestedClass, { rolesDir, overrideDir, base, over }),
|
||||
readPersonaFromLayer(requestedClass, canonicalClass, rolesDir, base, 'baseline'),
|
||||
]);
|
||||
return { requestedClass, persona, baseline };
|
||||
}),
|
||||
);
|
||||
|
||||
const personas = new Map<string, PersonaResolution>();
|
||||
const readableBaseline = new Set<string>();
|
||||
for (const { requestedClass, persona, baseline } of checked) {
|
||||
if (persona) personas.set(requestedClass, persona);
|
||||
if (baseline) readableBaseline.add(requestedClass);
|
||||
}
|
||||
return { personas, readableBaseline };
|
||||
return fromLayer(overrideDir, over, 'override') ?? fromLayer(rolesDir, base, 'baseline');
|
||||
}
|
||||
|
||||
export interface PersonaStatusEntry {
|
||||
@@ -608,20 +342,24 @@ export interface PersonaStatusEntry {
|
||||
* Domain is taken from the WINNING layer (override domain wins if present).
|
||||
*/
|
||||
export async function personaStatus(opts: PersonaDirs = {}): Promise<PersonaStatusEntry[]> {
|
||||
const { personas, readableBaseline } = await collectUsablePersonas(opts);
|
||||
const entries = [...personas.entries()].map(([klass, persona]): PersonaStatusEntry => {
|
||||
const status: PersonaStatus =
|
||||
persona.layer === 'baseline'
|
||||
? 'baseline'
|
||||
: readableBaseline.has(klass)
|
||||
? 'overridden'
|
||||
: 'custom';
|
||||
return {
|
||||
klass,
|
||||
status,
|
||||
...(persona.domain ? { domain: persona.domain } : {}),
|
||||
};
|
||||
});
|
||||
const { rolesDir, overrideDir } = resolveDirs(opts);
|
||||
const [base, over] = await Promise.all([
|
||||
extractClassesFromDir(rolesDir),
|
||||
extractClassesFromDir(overrideDir),
|
||||
]);
|
||||
|
||||
const all = new Set<string>([...base.classes, ...over.classes]);
|
||||
const domainOf = (extracted: DirClasses, klass: string): string | undefined =>
|
||||
extracted.byClass.get(klass)?.domain;
|
||||
|
||||
const entries: PersonaStatusEntry[] = [];
|
||||
for (const klass of all) {
|
||||
const inBase = base.classes.has(klass);
|
||||
const inOver = over.classes.has(klass);
|
||||
const status: PersonaStatus = inOver ? (inBase ? 'overridden' : 'custom') : 'baseline';
|
||||
const domain = (inOver ? domainOf(over, klass) : undefined) ?? domainOf(base, klass);
|
||||
entries.push({ klass, status, ...(domain ? { domain } : {}) });
|
||||
}
|
||||
entries.sort((a, b) => a.klass.localeCompare(b.klass));
|
||||
return entries;
|
||||
}
|
||||
|
||||
@@ -203,91 +203,6 @@ describe('loadProfiles with a temp override dir', () => {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('canonicalizes approved aliases across lead, floor, roster, and topology', async () => {
|
||||
await writeFile(
|
||||
join(dir, 'aliases.yaml'),
|
||||
[
|
||||
'id: aliases',
|
||||
'title: Aliases',
|
||||
'description: legacy class compatibility',
|
||||
'lead: operator-interaction',
|
||||
'floor: [operator-interaction]',
|
||||
'roster:',
|
||||
' - class: operator-interaction',
|
||||
' - class: implementer',
|
||||
' reports_to: operator-interaction',
|
||||
' - class: reviewer',
|
||||
' reports_to: operator-interaction',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
const profile = await loadProfile('aliases', {
|
||||
profilesDir: dir,
|
||||
rolesDir,
|
||||
overrideDir: join(dir, 'roles.local'),
|
||||
});
|
||||
expect(profile.lead).toBe('interaction');
|
||||
expect(profile.floor).toEqual(['interaction']);
|
||||
expect(profile.roster).toEqual([
|
||||
{ class: 'interaction', multiplicity: 1 },
|
||||
{ class: 'code', reportsTo: 'interaction', multiplicity: 1 },
|
||||
{ class: 'review', reportsTo: 'interaction', multiplicity: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects roster entries that collapse to the same canonical class', async () => {
|
||||
await writeFile(
|
||||
join(dir, 'alias-collision.yaml'),
|
||||
[
|
||||
'id: alias-collision',
|
||||
'title: Alias collision',
|
||||
'description: ambiguous canonical topology',
|
||||
'lead: orchestrator',
|
||||
'floor: [orchestrator]',
|
||||
'roster:',
|
||||
' - class: orchestrator',
|
||||
' - class: code',
|
||||
' reports_to: orchestrator',
|
||||
' - class: implementer',
|
||||
' reports_to: orchestrator',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
await expect(
|
||||
loadProfile('alias-collision', {
|
||||
profilesDir: dir,
|
||||
rolesDir,
|
||||
overrideDir: join(dir, 'roles.local'),
|
||||
}),
|
||||
).rejects.toThrow(/duplicate classes after canonicalization/);
|
||||
});
|
||||
|
||||
it('allows a readable lead or floor persona that is not itself a roster seat', async () => {
|
||||
await writeFile(
|
||||
join(dir, 'external-topology.yaml'),
|
||||
[
|
||||
'id: external-topology',
|
||||
'title: External topology',
|
||||
'description: structural compatibility',
|
||||
'lead: orchestrator',
|
||||
'floor: [enhancer]',
|
||||
'roster:',
|
||||
' - class: code',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
const profile = await loadProfile('external-topology', {
|
||||
profilesDir: dir,
|
||||
rolesDir,
|
||||
overrideDir: join(dir, 'roles.local'),
|
||||
});
|
||||
expect(profile.lead).toBe('orchestrator');
|
||||
expect(profile.floor).toEqual(['enhancer']);
|
||||
});
|
||||
|
||||
it('throws when a profile references an unknown class (validated against real roles)', async () => {
|
||||
await writeFile(
|
||||
join(dir, 'bad.yaml'),
|
||||
|
||||
@@ -29,7 +29,6 @@ import {
|
||||
defaultOverrideDir,
|
||||
extractClassesFromDir,
|
||||
listPersonaClasses as listOverrideAwarePersonaClasses,
|
||||
resolvePersonaFrom,
|
||||
} from './fleet-personas.js';
|
||||
|
||||
function defaultMosaicHome(): string {
|
||||
@@ -200,61 +199,6 @@ export function validateProfile(profile: FleetProfile, validClasses: Set<string>
|
||||
return problems;
|
||||
}
|
||||
|
||||
export async function resolveProfilePersonas(
|
||||
profile: FleetProfile,
|
||||
rolesDir: string,
|
||||
overrideDir: string,
|
||||
): Promise<FleetProfile> {
|
||||
const [base, over] = await Promise.all([
|
||||
extractClassesFromDir(rolesDir),
|
||||
extractClassesFromDir(overrideDir),
|
||||
]);
|
||||
const requestedClasses = new Set<string>([
|
||||
profile.lead,
|
||||
...profile.floor,
|
||||
...profile.roster.flatMap((entry: ProfileRosterEntry): string[] =>
|
||||
entry.reportsTo ? [entry.class, entry.reportsTo] : [entry.class],
|
||||
),
|
||||
]);
|
||||
const canonicalByRequested = new Map<string, string>();
|
||||
for (const requestedClass of requestedClasses) {
|
||||
const resolved = await resolvePersonaFrom(requestedClass, {
|
||||
rolesDir,
|
||||
overrideDir,
|
||||
base,
|
||||
over,
|
||||
});
|
||||
if (!resolved || resolved.content.trim() === '') {
|
||||
throw new Error(`persona class "${requestedClass}" does not resolve to a readable persona`);
|
||||
}
|
||||
canonicalByRequested.set(requestedClass, resolved.canonicalClass);
|
||||
}
|
||||
const canonical = (requestedClass: string): string =>
|
||||
canonicalByRequested.get(requestedClass) ?? requestedClass;
|
||||
const roster = profile.roster.map(
|
||||
(entry: ProfileRosterEntry): ProfileRosterEntry => ({
|
||||
class: canonical(entry.class),
|
||||
multiplicity: entry.multiplicity,
|
||||
...(entry.reportsTo ? { reportsTo: canonical(entry.reportsTo) } : {}),
|
||||
}),
|
||||
);
|
||||
const canonicalRosterClasses = roster.map((entry: ProfileRosterEntry): string => entry.class);
|
||||
if (new Set(canonicalRosterClasses).size !== canonicalRosterClasses.length) {
|
||||
throw new Error('profile roster contains duplicate classes after canonicalization');
|
||||
}
|
||||
const resolvedProfile: FleetProfile = {
|
||||
...profile,
|
||||
lead: canonical(profile.lead),
|
||||
floor: profile.floor.map(canonical),
|
||||
roster,
|
||||
};
|
||||
const problems = validateProfile(resolvedProfile, new Set(canonicalByRequested.values()));
|
||||
if (problems.length > 0) {
|
||||
throw new Error(problems.join('\n - '));
|
||||
}
|
||||
return resolvedProfile;
|
||||
}
|
||||
|
||||
export interface LoadProfilesOptions {
|
||||
/** Override the profiles dir (tests). Defaults to <mosaicHome>/fleet/profiles. */
|
||||
profilesDir?: string;
|
||||
@@ -293,8 +237,10 @@ export async function loadProfiles(opts: LoadProfilesOptions = {}): Promise<Flee
|
||||
}
|
||||
files.sort();
|
||||
|
||||
// Validation resolves each reference to an actually readable winning persona;
|
||||
// LIBRARY membership alone is not semantic success.
|
||||
// Override-aware: a profile may reference a user-customized or user-ADDED
|
||||
// persona living in the roles.local/ layer (H4), so validate against the
|
||||
// baseline ⊕ override union, not the baseline alone.
|
||||
const validClasses = await listPersonaClassesWithOverrides(rolesDir, overrideDir);
|
||||
const profiles: FleetProfile[] = [];
|
||||
const seen = new Map<string, string>();
|
||||
|
||||
@@ -309,14 +255,11 @@ export async function loadProfiles(opts: LoadProfilesOptions = {}): Promise<Flee
|
||||
}
|
||||
seen.set(profile.id, file);
|
||||
|
||||
let resolvedProfile: FleetProfile;
|
||||
try {
|
||||
resolvedProfile = await resolveProfilePersonas(profile, rolesDir, overrideDir);
|
||||
} catch (error: unknown) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`Profile ${file} is invalid:\n - ${detail}`);
|
||||
const problems = validateProfile(profile, validClasses);
|
||||
if (problems.length > 0) {
|
||||
throw new Error(`Profile ${file} is invalid:\n - ${problems.join('\n - ')}`);
|
||||
}
|
||||
profiles.push(resolvedProfile);
|
||||
profiles.push(profile);
|
||||
}
|
||||
return profiles;
|
||||
}
|
||||
|
||||
@@ -172,46 +172,6 @@ describe('override-aware persona validation', () => {
|
||||
expect(result.summary).toContain('persona=override');
|
||||
});
|
||||
|
||||
it('canonicalizes aliased profile classes and topology identities before emission', async () => {
|
||||
const overrideDir = join(dir, 'roles.local');
|
||||
const customProfilesDir = join(dir, 'profiles');
|
||||
await mkdir(overrideDir, { recursive: true });
|
||||
await mkdir(customProfilesDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(customProfilesDir, 'aliases.yaml'),
|
||||
[
|
||||
'id: aliases',
|
||||
'title: Aliases',
|
||||
'description: legacy aliases',
|
||||
'lead: operator-interaction',
|
||||
'floor: [operator-interaction]',
|
||||
'roster:',
|
||||
' - class: operator-interaction',
|
||||
' - class: implementer',
|
||||
' reports_to: operator-interaction',
|
||||
' - class: reviewer',
|
||||
' reports_to: operator-interaction',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
const result = await runProvision('aliases', {
|
||||
mosaicHome: dir,
|
||||
profilesDir: customProfilesDir,
|
||||
rolesDir,
|
||||
overrideDir,
|
||||
full: true,
|
||||
});
|
||||
expect(result.yaml).toContain('name: interaction');
|
||||
expect(result.yaml).toContain('class: interaction');
|
||||
expect(result.yaml).toContain('name: code');
|
||||
expect(result.yaml).toContain('class: code');
|
||||
expect(result.yaml).toContain('name: review');
|
||||
expect(result.yaml).toContain('class: review');
|
||||
expect(result.yaml).not.toContain('class: implementer');
|
||||
expect(result.summary).toContain('reports_to=interaction');
|
||||
});
|
||||
|
||||
it('FAILS with a clear message when a profile references a bogus class', async () => {
|
||||
const customProfilesDir = join(dir, 'profiles');
|
||||
await mkdir(customProfilesDir, { recursive: true });
|
||||
|
||||
@@ -26,11 +26,12 @@ import type { Command } from 'commander';
|
||||
import YAML from 'yaml';
|
||||
import {
|
||||
loadProfile,
|
||||
validateProfile,
|
||||
type FleetProfile,
|
||||
type ProfileRosterEntry,
|
||||
defaultProfilesDir,
|
||||
defaultRolesDir,
|
||||
resolveProfilePersonas,
|
||||
listPersonaClassesWithOverrides,
|
||||
} from './fleet-profiles.js';
|
||||
import {
|
||||
defaultOverrideDir,
|
||||
@@ -200,35 +201,18 @@ export async function generateRoster(
|
||||
);
|
||||
}
|
||||
|
||||
const canonicalEntry: ProfileRosterEntry = {
|
||||
class: resolved.canonicalClass,
|
||||
multiplicity: entry.multiplicity,
|
||||
...(entry.reportsTo
|
||||
? {
|
||||
reportsTo:
|
||||
(
|
||||
await resolvePersonaFrom(entry.reportsTo, {
|
||||
rolesDir,
|
||||
overrideDir,
|
||||
base,
|
||||
over,
|
||||
})
|
||||
)?.canonicalClass ?? entry.reportsTo,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
const runtimeChoice = resolveSeatRuntime(resolved.canonicalClass, isFloor, isLead);
|
||||
for (const name of seatNames(canonicalEntry)) {
|
||||
const runtimeChoice = resolveSeatRuntime(entry.class, isFloor, isLead);
|
||||
for (const name of seatNames(entry)) {
|
||||
const seat: GeneratedSeat = {
|
||||
name,
|
||||
className: resolved.canonicalClass,
|
||||
className: entry.class,
|
||||
runtime: runtimeChoice.runtime,
|
||||
personaLayer: resolved.layer,
|
||||
};
|
||||
if (runtimeChoice.modelHint) seat.modelHint = runtimeChoice.modelHint;
|
||||
if (isFloor || isLead) seat.persistentPersona = true;
|
||||
if (!isFloor && !isLead) seat.resetBetweenTasks = true;
|
||||
if (canonicalEntry.reportsTo) seat.reportsTo = canonicalEntry.reportsTo;
|
||||
if (entry.reportsTo) seat.reportsTo = entry.reportsTo;
|
||||
seats.push(seat);
|
||||
}
|
||||
}
|
||||
@@ -295,7 +279,13 @@ export async function validateProfileForProvision(
|
||||
opts: ProvisionOptions,
|
||||
): Promise<void> {
|
||||
const { rolesDir, overrideDir } = resolveDirs(opts);
|
||||
await resolveProfilePersonas(profile, rolesDir, overrideDir);
|
||||
const validClasses = await listPersonaClassesWithOverrides(rolesDir, overrideDir);
|
||||
const problems = validateProfile(profile, validClasses);
|
||||
if (problems.length > 0) {
|
||||
throw new Error(
|
||||
`Profile "${profile.id}" is invalid; cannot provision:\n - ${problems.join('\n - ')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,257 +0,0 @@
|
||||
import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Command } from 'commander';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { type FleetReconcileDeps } from '../fleet/fleet-reconciler.js';
|
||||
import { registerFleetCommand, type CommandResult, type FleetCommandDeps } from './fleet.js';
|
||||
|
||||
const roster = `
|
||||
version: 2
|
||||
generation: 7
|
||||
transport: tmux
|
||||
tmux:
|
||||
socket_name: mosaic-fleet
|
||||
holder_session: _holder
|
||||
defaults:
|
||||
working_directory: /srv/mosaic
|
||||
runtime: pi
|
||||
runtimes:
|
||||
pi:
|
||||
reset_command: /new
|
||||
agents:
|
||||
- name: coder0
|
||||
alias: Coder 0
|
||||
class: code
|
||||
runtime: pi
|
||||
provider: openai
|
||||
model: gpt-5.6-sol
|
||||
reasoning: high
|
||||
tool_policy: code
|
||||
working_directory: /srv/mosaic
|
||||
persistent_persona: false
|
||||
reset_between_tasks: true
|
||||
lifecycle:
|
||||
enabled: true
|
||||
desired_state: stopped
|
||||
launch:
|
||||
yolo: true
|
||||
`;
|
||||
|
||||
let cleanup: string | undefined;
|
||||
|
||||
afterEach(async (): Promise<void> => {
|
||||
vi.restoreAllMocks();
|
||||
process.exitCode = undefined;
|
||||
if (cleanup) await rm(cleanup, { recursive: true, force: true });
|
||||
cleanup = undefined;
|
||||
});
|
||||
|
||||
async function fleetHome(): Promise<string> {
|
||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-fleet-reconciler-command-'));
|
||||
for (const directory of ['fleet', 'fleet/agents', 'fleet/roles']) {
|
||||
await mkdir(join(cleanup, directory), { recursive: true, mode: 0o700 });
|
||||
await chmod(join(cleanup, directory), 0o700);
|
||||
}
|
||||
await chmod(cleanup, 0o700);
|
||||
await writeFile(join(cleanup, 'fleet', 'roster.yaml'), roster, { mode: 0o600 });
|
||||
await writeFile(join(cleanup, 'fleet', 'roles', 'code.md'), '# code\n\n(`class: code`)\n', {
|
||||
mode: 0o600,
|
||||
});
|
||||
return cleanup;
|
||||
}
|
||||
|
||||
function program(
|
||||
mosaicHome: string,
|
||||
runner: FleetCommandDeps['runner'],
|
||||
reconcileOverrides: Partial<FleetReconcileDeps> = {},
|
||||
): Command {
|
||||
const result = new Command();
|
||||
result.exitOverride();
|
||||
registerFleetCommand(result, {
|
||||
mosaicHome,
|
||||
runner,
|
||||
reconcileDeps: {
|
||||
homeDirectory: '/home/mosaic',
|
||||
readHolderIdentity: async () => '11111111-1111-4111-8111-111111111111',
|
||||
validateRoster: async () => undefined,
|
||||
prepareProjections: async () => [{ agentName: 'coder0' }],
|
||||
applyProjection: async () => undefined,
|
||||
...reconcileOverrides,
|
||||
},
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
function capture(): string[] {
|
||||
const lines: string[] = [];
|
||||
vi.spyOn(console, 'log').mockImplementation((value: string): void => {
|
||||
lines.push(value);
|
||||
});
|
||||
return lines;
|
||||
}
|
||||
|
||||
function ownedRunner(
|
||||
calls: string[][],
|
||||
): (command: string, args: string[]) => Promise<CommandResult> {
|
||||
return async (command: string, args: string[]): Promise<CommandResult> => {
|
||||
calls.push([command, ...args]);
|
||||
if (command === 'tmux' && args.includes('list-sessions')) {
|
||||
return { stdout: '_holder\ncoder0\n', stderr: '', exitCode: 0 };
|
||||
}
|
||||
if (command === 'tmux' && args.includes('show-environment')) {
|
||||
return {
|
||||
stdout:
|
||||
'HOME=/home/mosaic\nMOSAIC_FLEET_OWNER=11111111-1111-4111-8111-111111111111\nMOSAIC_TMUX_HOLDER=_holder\nMOSAIC_TMUX_SOCKET=mosaic-fleet\nPATH=/usr/bin:/bin\nPWD=/home/mosaic\n',
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
};
|
||||
}
|
||||
return { stdout: '', stderr: '', exitCode: 0 };
|
||||
};
|
||||
}
|
||||
|
||||
describe('mosaic fleet reconciler commands', (): void => {
|
||||
it('plans apply as stable JSON without applying projections or lifecycle effects', async (): Promise<void> => {
|
||||
const home = await fleetHome();
|
||||
const calls: string[][] = [];
|
||||
const lines = capture();
|
||||
|
||||
await program(home, ownedRunner(calls)).parseAsync([
|
||||
'node',
|
||||
'mosaic',
|
||||
'fleet',
|
||||
'apply',
|
||||
'--expected-generation',
|
||||
'7',
|
||||
'--dry-run',
|
||||
]);
|
||||
|
||||
expect(JSON.parse(lines.pop() ?? '')).toMatchObject({
|
||||
applied: false,
|
||||
authoritativeRoster: 'unchanged',
|
||||
projections: 'not-applied',
|
||||
lifecycle: 'not-applied',
|
||||
});
|
||||
expect(calls).not.toContainEqual([
|
||||
'systemctl',
|
||||
'--user',
|
||||
'start',
|
||||
'mosaic-agent@coder0.service',
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses direct fleet status and doctor as observational JSON commands', async (): Promise<void> => {
|
||||
const home = await fleetHome();
|
||||
const lines = capture();
|
||||
const calls: string[][] = [];
|
||||
const cli = program(home, ownedRunner(calls));
|
||||
|
||||
await cli.parseAsync(['node', 'mosaic', 'fleet', 'status', 'coder0']);
|
||||
await cli.parseAsync(['node', 'mosaic', 'fleet', 'doctor']);
|
||||
|
||||
expect(lines.map((line: string): unknown => JSON.parse(line))).toMatchObject([
|
||||
{ applied: false, lifecycle: 'not-applied' },
|
||||
{ applied: false, lifecycle: 'not-applied' },
|
||||
]);
|
||||
expect(
|
||||
calls.every((call: string[]): boolean => call[0] !== 'systemctl' || call[2] === 'show'),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it.each(['start', 'stop', 'restart'] as const)(
|
||||
'uses exact roster-owned systemd targeting for %s',
|
||||
async (operation: 'start' | 'stop' | 'restart'): Promise<void> => {
|
||||
const home = await fleetHome();
|
||||
const calls: string[][] = [];
|
||||
const lines = capture();
|
||||
|
||||
await program(home, ownedRunner(calls)).parseAsync([
|
||||
'node',
|
||||
'mosaic',
|
||||
'fleet',
|
||||
operation,
|
||||
'coder0',
|
||||
'--expected-generation',
|
||||
'7',
|
||||
]);
|
||||
|
||||
expect(JSON.parse(lines.pop() ?? '')).toMatchObject({ applied: true, lifecycle: 'complete' });
|
||||
expect(calls).toContainEqual([
|
||||
'systemctl',
|
||||
'--user',
|
||||
operation,
|
||||
'mosaic-agent@coder0.service',
|
||||
]);
|
||||
expect(calls.some((call: string[]): boolean => call.join(' ').includes('coder0-extra'))).toBe(
|
||||
false,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('makes cleanup-incomplete effect JSON non-zero without losing effect truth', async (): Promise<void> => {
|
||||
const home = await fleetHome();
|
||||
const lines = capture();
|
||||
await program(home, ownedRunner([]), {
|
||||
acquireMutationLock: async () => async () => {
|
||||
throw new Error('cleanup failure');
|
||||
},
|
||||
}).parseAsync(['node', 'mosaic', 'fleet', 'apply', '--expected-generation', '7']);
|
||||
|
||||
expect(JSON.parse(lines.pop() ?? '')).toMatchObject({
|
||||
applied: true,
|
||||
projections: 'complete',
|
||||
lifecycle: 'complete',
|
||||
cleanup: { code: 'lock-cleanup-failed', action: 'inspect-lock-before-retry' },
|
||||
});
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it('keeps primary partial recovery JSON and exits non-zero when cleanup is also incomplete', async (): Promise<void> => {
|
||||
const cleanupFailure = async () => async () => {
|
||||
throw new Error('cleanup failure');
|
||||
};
|
||||
const projectionHome = await fleetHome();
|
||||
const projectionLines = capture();
|
||||
await program(projectionHome, ownedRunner([]), {
|
||||
applyProjection: async () => {
|
||||
throw new Error('projection failure');
|
||||
},
|
||||
acquireMutationLock: cleanupFailure,
|
||||
}).parseAsync(['node', 'mosaic', 'fleet', 'apply', '--expected-generation', '7']);
|
||||
expect(JSON.parse(projectionLines.pop() ?? '')).toMatchObject({
|
||||
projections: 'incomplete',
|
||||
lifecycle: 'not-applied',
|
||||
recovery: { code: 'projection-apply-failed' },
|
||||
cleanup: { code: 'lock-cleanup-failed' },
|
||||
});
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it('keeps clean effect and observational commands at zero exit', async (): Promise<void> => {
|
||||
const home = await fleetHome();
|
||||
const cli = program(home, ownedRunner([]));
|
||||
await cli.parseAsync(['node', 'mosaic', 'fleet', 'apply', '--expected-generation', '7']);
|
||||
expect(process.exitCode).toBe(0);
|
||||
process.exitCode = undefined;
|
||||
await cli.parseAsync(['node', 'mosaic', 'fleet', 'status']);
|
||||
expect(process.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
it('rejects stale apply generations as non-zero redacted JSON', async (): Promise<void> => {
|
||||
const home = await fleetHome();
|
||||
const lines = capture();
|
||||
|
||||
await program(home, ownedRunner([])).parseAsync([
|
||||
'node',
|
||||
'mosaic',
|
||||
'fleet',
|
||||
'apply',
|
||||
'--expected-generation',
|
||||
'6',
|
||||
]);
|
||||
|
||||
expect(JSON.parse(lines.pop() ?? '')).toEqual({ error: { code: 'stale-generation' } });
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -1,146 +0,0 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { join, resolve } from 'node:path';
|
||||
import type { Command } from 'commander';
|
||||
import type { CommandRunner } from './fleet.js';
|
||||
import {
|
||||
executeFleetReconcile,
|
||||
FleetReconcileError,
|
||||
type FleetReconcileCommand,
|
||||
type FleetReconcileDeps,
|
||||
} from '../fleet/fleet-reconciler.js';
|
||||
import { parseRosterV2 } from '../fleet/roster-v2.js';
|
||||
|
||||
export interface FleetReconcilerCommandDeps {
|
||||
readonly runner: CommandRunner;
|
||||
readonly mosaicHome?: string;
|
||||
readonly reconcileDeps?: Omit<FleetReconcileDeps, 'runner' | 'mosaicHome'>;
|
||||
}
|
||||
|
||||
interface ReconcileOptions {
|
||||
readonly expectedGeneration?: string;
|
||||
readonly dryRun?: boolean;
|
||||
}
|
||||
|
||||
/** Registers roster-v2 reconciliation commands on the canonical fleet control plane. */
|
||||
export function registerFleetReconcilerCommands(
|
||||
fleetCommand: Command,
|
||||
deps: FleetReconcilerCommandDeps,
|
||||
): void {
|
||||
for (const operation of ['apply', 'reconcile'] as const) {
|
||||
fleetCommand
|
||||
.command(operation)
|
||||
.description(`${operation} local roster-owned projections and desired lifecycle`)
|
||||
.requiredOption('--expected-generation <number>', 'Authoritative roster generation')
|
||||
.option('--dry-run', 'Plan and preflight without writing projections or lifecycle state')
|
||||
.action(async (opts: ReconcileOptions): Promise<void> => {
|
||||
await writeOutcome(async (): Promise<void> => {
|
||||
await executeReconcilerCommand(fleetCommand, deps, operation, opts);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fleetCommand
|
||||
.command('doctor')
|
||||
.description('Classify local roster-owned drift without mutation')
|
||||
.action(async (): Promise<void> => {
|
||||
await writeOutcome(async (): Promise<void> => {
|
||||
await executeReconcilerCommand(fleetCommand, deps, 'doctor', {});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function executeReconcilerCommandJson(
|
||||
fleetCommand: Command,
|
||||
deps: FleetReconcilerCommandDeps,
|
||||
operation: FleetReconcileCommand,
|
||||
opts: ReconcileOptions,
|
||||
agentName?: string,
|
||||
): Promise<void> {
|
||||
await writeOutcome(async (): Promise<void> => {
|
||||
await executeReconcilerCommand(fleetCommand, deps, operation, opts, agentName);
|
||||
});
|
||||
}
|
||||
|
||||
export async function executeReconcilerCommand(
|
||||
fleetCommand: Command,
|
||||
deps: FleetReconcilerCommandDeps,
|
||||
operation: FleetReconcileCommand,
|
||||
opts: ReconcileOptions,
|
||||
agentName?: string,
|
||||
): Promise<void> {
|
||||
const mosaicHome = resolveMosaicHome(fleetCommand, deps);
|
||||
const rosterPath = resolveRosterPath(fleetCommand, mosaicHome);
|
||||
const roster = parseRosterV2(await readFile(rosterPath, 'utf8'), 'yaml');
|
||||
const mutating = operation === 'apply' || operation === 'reconcile' || isLifecycle(operation);
|
||||
const expectedGeneration = mutating
|
||||
? parseExpectedGeneration(opts.expectedGeneration)
|
||||
: undefined;
|
||||
const result = await executeFleetReconcile({
|
||||
roster,
|
||||
command: opts.dryRun === true ? 'plan' : operation,
|
||||
...(agentName === undefined ? {} : { agentName }),
|
||||
...(expectedGeneration === undefined ? {} : { expectedGeneration }),
|
||||
deps: {
|
||||
runner: async (command: string, args: readonly string[]) => deps.runner(command, [...args]),
|
||||
mosaicHome,
|
||||
rolesDir: join(mosaicHome, 'fleet', 'roles'),
|
||||
overrideDir: join(mosaicHome, 'fleet', 'roles.local'),
|
||||
readRoster: async (): Promise<typeof roster> =>
|
||||
parseRosterV2(await readFile(rosterPath, 'utf8'), 'yaml'),
|
||||
...(deps.reconcileDeps ?? {}),
|
||||
},
|
||||
});
|
||||
printJson(result);
|
||||
process.exitCode = result.recovery === undefined && result.cleanup === undefined ? 0 : 1;
|
||||
}
|
||||
|
||||
function isLifecycle(operation: FleetReconcileCommand): boolean {
|
||||
return operation === 'start' || operation === 'stop' || operation === 'restart';
|
||||
}
|
||||
|
||||
function parseExpectedGeneration(value: string | undefined): number {
|
||||
const generation = Number(value);
|
||||
if (!Number.isSafeInteger(generation) || generation < 1) {
|
||||
throw new FleetReconcileError(
|
||||
'lifecycle-precondition-failed',
|
||||
'--expected-generation must be a positive safe integer.',
|
||||
);
|
||||
}
|
||||
return generation;
|
||||
}
|
||||
|
||||
function resolveMosaicHome(fleetCommand: Command, deps: FleetReconcilerCommandDeps): string {
|
||||
const options = fleetCommand.optsWithGlobals<{ mosaicHome?: string }>();
|
||||
return (
|
||||
options.mosaicHome ?? deps.mosaicHome ?? join(process.env['HOME'] ?? '', '.config', 'mosaic')
|
||||
);
|
||||
}
|
||||
|
||||
function resolveRosterPath(fleetCommand: Command, mosaicHome: string): string {
|
||||
const options = fleetCommand.optsWithGlobals<{ roster?: string }>();
|
||||
const canonical = join(mosaicHome, 'fleet', 'roster.yaml');
|
||||
if (options.roster !== undefined && resolve(options.roster) !== resolve(canonical)) {
|
||||
throw new FleetReconcileError(
|
||||
'lifecycle-precondition-failed',
|
||||
'Roster-v2 reconciliation requires the canonical roster path.',
|
||||
);
|
||||
}
|
||||
return canonical;
|
||||
}
|
||||
|
||||
async function writeOutcome(action: () => Promise<void>): Promise<void> {
|
||||
try {
|
||||
await action();
|
||||
} catch (error: unknown) {
|
||||
process.exitCode = 1;
|
||||
printJson({
|
||||
error: {
|
||||
code: error instanceof FleetReconcileError ? error.code : 'reconcile-failed',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function printJson(value: object): void {
|
||||
console.log(JSON.stringify(value));
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { chmod, lstat, mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { Command } from 'commander';
|
||||
@@ -81,27 +81,19 @@ describe('registerFleetCommand', () => {
|
||||
expect(fleet).toBeDefined();
|
||||
expect(fleet!.commands.map((command) => command.name()).sort()).toEqual([
|
||||
'add',
|
||||
'apply',
|
||||
'backlog',
|
||||
'create',
|
||||
'delete',
|
||||
'doctor',
|
||||
'get',
|
||||
'init',
|
||||
'install',
|
||||
'install-systemd',
|
||||
'persona',
|
||||
'plan',
|
||||
'profile',
|
||||
'provision',
|
||||
'ps',
|
||||
'reconcile',
|
||||
'remove',
|
||||
'restart',
|
||||
'start',
|
||||
'status',
|
||||
'stop',
|
||||
'update',
|
||||
'verify',
|
||||
]);
|
||||
});
|
||||
@@ -292,8 +284,6 @@ describe('fleet roster parsing', () => {
|
||||
'MOSAIC_AGENT_CLASS=implementer',
|
||||
'MOSAIC_AGENT_RUNTIME=codex',
|
||||
'MOSAIC_AGENT_MODEL=',
|
||||
'MOSAIC_AGENT_REASONING=',
|
||||
'MOSAIC_AGENT_TOOL_POLICY=',
|
||||
'MOSAIC_AGENT_WORKDIR=/srv/mosaic',
|
||||
'MOSAIC_TMUX_SOCKET=mosaic-fleet',
|
||||
'',
|
||||
@@ -335,38 +325,57 @@ describe('fleet roster parsing', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects legacy command overrides instead of merging them into generated authority', () => {
|
||||
const generated = generateAgentEnv(
|
||||
{
|
||||
version: 1,
|
||||
transport: 'tmux',
|
||||
tmux: { socketName: 'mosaic-fleet', holderSession: '_holder' },
|
||||
defaults: { workingDirectory: '/srv/new' },
|
||||
runtimes: {},
|
||||
agents: [],
|
||||
},
|
||||
{ name: 'coder0', className: 'code', runtime: 'codex' },
|
||||
);
|
||||
it('preserves site-owned agent EnvironmentFile overrides while refreshing roster keys', () => {
|
||||
const generated = [
|
||||
'MOSAIC_AGENT_NAME=coder0',
|
||||
'MOSAIC_AGENT_RUNTIME=codex',
|
||||
'MOSAIC_AGENT_WORKDIR=/srv/new',
|
||||
'MOSAIC_TMUX_SOCKET=mosaic-fleet',
|
||||
'',
|
||||
].join('\n');
|
||||
const existing = [
|
||||
'MOSAIC_AGENT_NAME=old-name',
|
||||
'MOSAIC_AGENT_RUNTIME=old-runtime',
|
||||
'MOSAIC_AGENT_WORKDIR=/srv/old',
|
||||
'MOSAIC_TMUX_SOCKET=old-socket',
|
||||
'MOSAIC_AGENT_COMMAND=/home/jarvis/.config/mosaic/fleet/canary.sh',
|
||||
'# site note',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
expect((): string => mergeAgentEnv(generated, 'MOSAIC_AGENT_COMMAND=legacy-command\n')).toThrow(
|
||||
/MOSAIC_AGENT_COMMAND/,
|
||||
expect(mergeAgentEnv(generated, existing)).toBe(
|
||||
[
|
||||
'MOSAIC_AGENT_NAME=coder0',
|
||||
'MOSAIC_AGENT_RUNTIME=codex',
|
||||
'MOSAIC_AGENT_WORKDIR=/srv/new',
|
||||
'MOSAIC_TMUX_SOCKET=mosaic-fleet',
|
||||
'MOSAIC_AGENT_COMMAND=/home/jarvis/.config/mosaic/fleet/canary.sh',
|
||||
'# site note',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not merge a valid local value into the generated projection', () => {
|
||||
const generated = generateAgentEnv(
|
||||
{
|
||||
version: 1,
|
||||
transport: 'tmux',
|
||||
tmux: { socketName: 'mosaic-fleet', holderSession: '_holder' },
|
||||
defaults: { workingDirectory: '/srv/new' },
|
||||
runtimes: {},
|
||||
agents: [],
|
||||
},
|
||||
{ name: 'coder0', className: 'code', runtime: 'codex' },
|
||||
);
|
||||
it('updates (does not duplicate) MOSAIC_AGENT_CLASS on re-launch', () => {
|
||||
const generated = [
|
||||
'MOSAIC_AGENT_NAME=coder0',
|
||||
'MOSAIC_AGENT_CLASS=orchestrator',
|
||||
'MOSAIC_AGENT_RUNTIME=codex',
|
||||
'',
|
||||
].join('\n');
|
||||
const existing = [
|
||||
'MOSAIC_AGENT_NAME=coder0',
|
||||
'MOSAIC_AGENT_CLASS=worker',
|
||||
'MOSAIC_AGENT_RUNTIME=codex',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
expect(mergeAgentEnv(generated, 'MOSAIC_RUNTIME_BIN=/opt/mosaic/bin\n')).toBe(generated);
|
||||
const merged = mergeAgentEnv(generated, existing);
|
||||
// mergeAgentEnv keys by VAR name, so the regenerated CLASS wins and there is
|
||||
// exactly one MOSAIC_AGENT_CLASS line (no stale worker value left behind).
|
||||
expect(merged).toContain('MOSAIC_AGENT_CLASS=orchestrator');
|
||||
expect(merged).not.toContain('MOSAIC_AGENT_CLASS=worker');
|
||||
expect(merged.match(/^MOSAIC_AGENT_CLASS=/gm)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('rejects unknown roster fields instead of silently defaulting', async () => {
|
||||
@@ -493,13 +502,7 @@ describe('fleet command construction', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('runs legacy fleet status through injected runner without touching tmux in tests', async () => {
|
||||
const home = await tempDir();
|
||||
await mkdir(join(home, 'fleet'), { recursive: true });
|
||||
await writeFile(
|
||||
join(home, 'fleet', 'roster.yaml'),
|
||||
'version: 1\ntransport: tmux\nagents: []\n',
|
||||
);
|
||||
it('runs fleet status through injected runner without touching tmux in tests', async () => {
|
||||
const calls: string[][] = [];
|
||||
const runner: CommandRunner = async (command, args) => {
|
||||
calls.push([command, ...args]);
|
||||
@@ -507,14 +510,11 @@ describe('fleet command construction', () => {
|
||||
};
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerFleetCommand(program, { runner, mosaicHome: home });
|
||||
registerFleetCommand(program, { runner });
|
||||
|
||||
try {
|
||||
await program.parseAsync(['node', 'mosaic', 'fleet', 'status']);
|
||||
expect(calls).toEqual([['systemctl', '--user', 'status', 'mosaic-tmux-holder.service']]);
|
||||
} finally {
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
await program.parseAsync(['node', 'mosaic', 'fleet', 'status']);
|
||||
|
||||
expect(calls).toEqual([['systemctl', '--user', 'status', 'mosaic-tmux-holder.service']]);
|
||||
});
|
||||
|
||||
it('verifies liveness with tmux has-session and does not trust systemd active exited', async () => {
|
||||
@@ -630,19 +630,13 @@ describe('fleet command construction', () => {
|
||||
).rejects.toThrow('Unsupported fleet profile');
|
||||
});
|
||||
|
||||
it('sets process exitCode when legacy status runner fails', async () => {
|
||||
const home = await tempDir();
|
||||
await mkdir(join(home, 'fleet'), { recursive: true });
|
||||
await writeFile(
|
||||
join(home, 'fleet', 'roster.yaml'),
|
||||
'version: 1\ntransport: tmux\nagents: []\n',
|
||||
);
|
||||
it('sets process exitCode when status runner fails', async () => {
|
||||
const originalExitCode = process.exitCode;
|
||||
const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
||||
const runner: CommandRunner = async () => ({ stdout: '', stderr: 'missing\n', exitCode: 3 });
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerFleetCommand(program, { runner, mosaicHome: home });
|
||||
registerFleetCommand(program, { runner });
|
||||
|
||||
try {
|
||||
await program.parseAsync(['node', 'mosaic', 'fleet', 'status']);
|
||||
@@ -650,7 +644,6 @@ describe('fleet command construction', () => {
|
||||
} finally {
|
||||
process.exitCode = originalExitCode;
|
||||
stderrSpy.mockRestore();
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1136,92 +1129,6 @@ describe('fleet command construction', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('creates private managed directories and roster/projection files through fresh init and install under umask 022', async () => {
|
||||
const originalHome = process.env.HOME;
|
||||
const originalMosaicHome = process.env.MOSAIC_HOME;
|
||||
const originalUmask = process.umask(0o022);
|
||||
const home = await tempDir();
|
||||
process.env.HOME = home;
|
||||
delete process.env.MOSAIC_HOME;
|
||||
const mosaicHome = join(home, '.config', 'mosaic');
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerFleetCommand(program, { frameworkRoot: resolve(process.cwd(), 'framework') });
|
||||
|
||||
try {
|
||||
await expect(
|
||||
program.parseAsync(['node', 'mosaic', 'fleet', 'init', '--profile', 'minimal', '--write']),
|
||||
).resolves.toBeDefined();
|
||||
await expect(
|
||||
program.parseAsync(['node', 'mosaic', 'fleet', 'install', '--no-enable']),
|
||||
).resolves.toBeDefined();
|
||||
|
||||
for (const path of [
|
||||
mosaicHome,
|
||||
join(mosaicHome, 'fleet'),
|
||||
join(mosaicHome, 'fleet', 'agents'),
|
||||
]) {
|
||||
expect((await stat(path)).mode & 0o777).toBe(0o700);
|
||||
}
|
||||
expect((await stat(join(mosaicHome, 'fleet', 'roster.yaml'))).mode & 0o777).toBe(0o600);
|
||||
expect(
|
||||
(await stat(join(mosaicHome, 'fleet', 'agents', 'canary-pi.env.generated'))).mode & 0o777,
|
||||
).toBe(0o600);
|
||||
} finally {
|
||||
process.umask(originalUmask);
|
||||
if (originalHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = originalHome;
|
||||
if (originalMosaicHome === undefined) delete process.env.MOSAIC_HOME;
|
||||
else process.env.MOSAIC_HOME = originalMosaicHome;
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('creates a private real projection directory and private generated files on fresh install', async () => {
|
||||
const originalHome = process.env.HOME;
|
||||
const home = await tempDir();
|
||||
process.env.HOME = home;
|
||||
const mosaicHome = join(home, '.config', 'mosaic');
|
||||
const agentEnvDir = join(mosaicHome, 'fleet', 'agents');
|
||||
const fleetDir = join(mosaicHome, 'fleet');
|
||||
await mkdir(fleetDir, { recursive: true, mode: 0o700 });
|
||||
await chmod(mosaicHome, 0o700);
|
||||
await chmod(fleetDir, 0o700);
|
||||
await writeFile(
|
||||
join(mosaicHome, 'fleet', 'roster.yaml'),
|
||||
[
|
||||
'version: 1',
|
||||
'transport: tmux',
|
||||
'agents:',
|
||||
' - name: coder0',
|
||||
' runtime: pi',
|
||||
' class: code',
|
||||
].join('\n'),
|
||||
);
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerFleetCommand(program, {
|
||||
frameworkRoot: resolve(process.cwd(), 'framework'),
|
||||
mosaicHome,
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(
|
||||
program.parseAsync(['node', 'mosaic', 'fleet', 'install', '--no-enable']),
|
||||
).resolves.toBeDefined();
|
||||
|
||||
const directoryMetadata = await lstat(agentEnvDir);
|
||||
expect(directoryMetadata.isDirectory()).toBe(true);
|
||||
expect(directoryMetadata.isSymbolicLink()).toBe(false);
|
||||
expect(directoryMetadata.mode & 0o777).toBe(0o700);
|
||||
expect((await stat(join(agentEnvDir, 'coder0.env.generated'))).mode & 0o777).toBe(0o600);
|
||||
} finally {
|
||||
if (originalHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = originalHome;
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it.each(['start', 'stop', 'restart', 'status'] as const)(
|
||||
'rejects single-agent %s for agents outside the roster',
|
||||
async (action) => {
|
||||
@@ -1780,10 +1687,8 @@ describe('fleet ps — drift detection', () => {
|
||||
describe('fleet-polish bundle — boot-survival symmetry', () => {
|
||||
async function rosterHome(agents: string): Promise<string> {
|
||||
const home = await tempDir();
|
||||
const fleetDir = join(home, 'fleet');
|
||||
await mkdir(fleetDir, { recursive: true, mode: 0o700 });
|
||||
await chmod(fleetDir, 0o700);
|
||||
await writeFile(join(fleetDir, 'roster.yaml'), agents);
|
||||
await mkdir(join(home, 'fleet'), { recursive: true });
|
||||
await writeFile(join(home, 'fleet', 'roster.yaml'), agents);
|
||||
return home;
|
||||
}
|
||||
|
||||
@@ -3554,11 +3459,7 @@ describe('fleet add command', () => {
|
||||
|
||||
async function makeHome(agents = ['orchestrator']): Promise<string> {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'mosaic-fleet-add-'));
|
||||
const fleetDir = join(dir, 'fleet');
|
||||
const agentEnvDir = join(fleetDir, 'agents');
|
||||
await mkdir(agentEnvDir, { recursive: true, mode: 0o700 });
|
||||
await chmod(fleetDir, 0o700);
|
||||
await chmod(agentEnvDir, 0o700);
|
||||
await mkdir(join(dir, 'fleet', 'agents'), { recursive: true });
|
||||
const agentLines = agents.map((name) => {
|
||||
const cls = name === 'orchestrator' ? 'orchestrator' : 'worker';
|
||||
return ` - name: ${name}\n runtime: claude\n class: ${cls}`;
|
||||
@@ -3592,110 +3493,11 @@ describe('fleet add command', () => {
|
||||
const roster = await loadFleetRoster(join(home, 'fleet', 'roster.yaml'));
|
||||
expect(roster.agents.map((a) => a.name)).toContain('coder0');
|
||||
|
||||
const envContent = await readFile(
|
||||
join(home, 'fleet', 'agents', 'coder0.env.generated'),
|
||||
'utf8',
|
||||
);
|
||||
const envContent = await readFile(join(home, 'fleet', 'agents', 'coder0.env'), 'utf8');
|
||||
expect(envContent).toContain('MOSAIC_AGENT_NAME=coder0');
|
||||
expect(envContent).toContain('MOSAIC_AGENT_RUNTIME=codex');
|
||||
});
|
||||
|
||||
it('rejects an unprojectable dogfood runtime without mutating roster or projection state', async () => {
|
||||
home = await makeHome();
|
||||
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
||||
const agentEnvDir = join(home, 'fleet', 'agents');
|
||||
const trackedPaths = [
|
||||
rosterPath,
|
||||
join(agentEnvDir, 'dogfood.env.generated'),
|
||||
join(agentEnvDir, 'dogfood.env.local'),
|
||||
join(agentEnvDir, 'dogfood.env.quarantine'),
|
||||
];
|
||||
await writeFile(trackedPaths[1]!, 'generated-before\n');
|
||||
await writeFile(trackedPaths[2]!, 'MOSAIC_RUNTIME_BIN=/safe/runtime\n');
|
||||
await writeFile(trackedPaths[3]!, 'quarantine-before\n');
|
||||
const beforeState = new Map<string, Buffer>();
|
||||
for (const path of trackedPaths) beforeState.set(path, await readFile(path));
|
||||
|
||||
const calls: string[][] = [];
|
||||
const runner: CommandRunner = async (command, args) => {
|
||||
calls.push([command, ...args]);
|
||||
return { stdout: '', stderr: '', exitCode: 0 };
|
||||
};
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerFleetCommand(program, { runner, mosaicHome: home });
|
||||
|
||||
await expect(
|
||||
program.parseAsync([
|
||||
'node',
|
||||
'mosaic',
|
||||
'fleet',
|
||||
'add',
|
||||
'dogfood',
|
||||
'--runtime',
|
||||
'dogfood',
|
||||
'--class',
|
||||
'worker',
|
||||
'--no-start',
|
||||
]),
|
||||
).rejects.toThrow(/runtime/i);
|
||||
|
||||
for (const path of trackedPaths) {
|
||||
expect(await readFile(path)).toEqual(beforeState.get(path));
|
||||
}
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects supported add with a deterministic quarantine conflict before roster persistence', async () => {
|
||||
home = await makeHome();
|
||||
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
||||
const agentEnvDir = join(home, 'fleet', 'agents');
|
||||
const trackedPaths = [
|
||||
rosterPath,
|
||||
join(agentEnvDir, 'coder1.env.generated'),
|
||||
join(agentEnvDir, 'coder1.env.local'),
|
||||
join(agentEnvDir, 'coder1.env'),
|
||||
join(agentEnvDir, 'coder1.env.quarantine'),
|
||||
];
|
||||
await writeFile(trackedPaths[1]!, 'generated-before\n', { mode: 0o600 });
|
||||
await writeFile(trackedPaths[2]!, 'MOSAIC_RUNTIME_BIN=/safe/runtime\n', { mode: 0o600 });
|
||||
await writeFile(trackedPaths[3]!, 'MOSAIC_AGENT_COMMAND=must-be-quarantined\n', {
|
||||
mode: 0o600,
|
||||
});
|
||||
await writeFile(trackedPaths[4]!, 'quarantine-before\n', { mode: 0o600 });
|
||||
const beforeState = new Map<string, Buffer>();
|
||||
for (const path of trackedPaths) beforeState.set(path, await readFile(path));
|
||||
|
||||
const calls: string[][] = [];
|
||||
const runner: CommandRunner = async (command, args) => {
|
||||
calls.push([command, ...args]);
|
||||
return { stdout: '', stderr: '', exitCode: 0 };
|
||||
};
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerFleetCommand(program, { runner, mosaicHome: home });
|
||||
|
||||
await expect(
|
||||
program.parseAsync([
|
||||
'node',
|
||||
'mosaic',
|
||||
'fleet',
|
||||
'add',
|
||||
'coder1',
|
||||
'--runtime',
|
||||
'codex',
|
||||
'--class',
|
||||
'worker',
|
||||
'--no-start',
|
||||
]),
|
||||
).rejects.toThrow('quarantine-exists');
|
||||
|
||||
for (const path of trackedPaths) {
|
||||
expect(await readFile(path)).toEqual(beforeState.get(path));
|
||||
}
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
it('--no-start skips the start command', async () => {
|
||||
home = await makeHome();
|
||||
const calls: string[][] = [];
|
||||
@@ -3857,10 +3659,7 @@ describe('fleet remove command', () => {
|
||||
].join('\n'),
|
||||
);
|
||||
// Create env and heartbeat files for coder0
|
||||
await writeFile(
|
||||
join(dir, 'fleet', 'agents', 'coder0.env.generated'),
|
||||
'MOSAIC_AGENT_NAME=coder0\n',
|
||||
);
|
||||
await writeFile(join(dir, 'fleet', 'agents', 'coder0.env'), 'MOSAIC_AGENT_NAME=coder0\n');
|
||||
await writeFile(join(dir, 'fleet', 'run', 'coder0.hb'), 'ts=2026-01-01T00:00:00.000Z\n');
|
||||
return dir;
|
||||
}
|
||||
@@ -3939,15 +3738,12 @@ describe('fleet remove command', () => {
|
||||
|
||||
await program.parseAsync(['node', 'mosaic', 'fleet', 'remove', 'coder0', '--keep-files']);
|
||||
|
||||
// Generated projection should still exist.
|
||||
const envContent = await readFile(
|
||||
join(home, 'fleet', 'agents', 'coder0.env.generated'),
|
||||
'utf8',
|
||||
);
|
||||
// Env file should still exist
|
||||
const envContent = await readFile(join(home, 'fleet', 'agents', 'coder0.env'), 'utf8');
|
||||
expect(envContent).toContain('MOSAIC_AGENT_NAME=coder0');
|
||||
});
|
||||
|
||||
it('generated projection is removed by default (no --keep-files)', async () => {
|
||||
it('env file is removed by default (no --keep-files)', async () => {
|
||||
home = await makeHome();
|
||||
const runner: CommandRunner = async () => ({ stdout: '', stderr: '', exitCode: 0 });
|
||||
const program = new Command();
|
||||
@@ -3956,9 +3752,7 @@ describe('fleet remove command', () => {
|
||||
|
||||
await program.parseAsync(['node', 'mosaic', 'fleet', 'remove', 'coder0']);
|
||||
|
||||
await expect(
|
||||
readFile(join(home, 'fleet', 'agents', 'coder0.env.generated'), 'utf8'),
|
||||
).rejects.toThrow();
|
||||
await expect(readFile(join(home, 'fleet', 'agents', 'coder0.env'), 'utf8')).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('removing the sole orchestrator throws with a clear error about the guard', async () => {
|
||||
|
||||
@@ -18,26 +18,7 @@ import { spawn } from 'node:child_process';
|
||||
import * as readline from 'node:readline';
|
||||
import type { Command } from 'commander';
|
||||
import YAML from 'yaml';
|
||||
import {
|
||||
registerFleetAgentCrudCommands,
|
||||
type FleetAgentCrudCommandDeps,
|
||||
} from './fleet-agent-crud-command.js';
|
||||
import {
|
||||
executeReconcilerCommandJson,
|
||||
registerFleetReconcilerCommands,
|
||||
type FleetReconcilerCommandDeps,
|
||||
} from './fleet-reconciler-command.js';
|
||||
import { resolveCommsBlock } from '../fleet/comms-onboarding.js';
|
||||
import {
|
||||
applyPreparedAgentEnvironmentProjection,
|
||||
ensureFleetHolderIdentity,
|
||||
GENERATED_AGENT_ENV_SUPPORTED_RUNTIMES,
|
||||
parseAgentEnvironment,
|
||||
prepareAgentEnvironmentProjection,
|
||||
renderGeneratedAgentEnvironment,
|
||||
writeAgentEnvironmentProjection,
|
||||
writeManagedFleetRoster,
|
||||
} from '../fleet/generated-env-boundary.js';
|
||||
import { registerFleetBacklogCommand } from './fleet-backlog.js';
|
||||
import { registerFleetPersonaCommand } from './fleet-personas.js';
|
||||
import { registerFleetProfileCommand } from './fleet-profiles.js';
|
||||
@@ -82,8 +63,6 @@ export interface FleetCommandDeps {
|
||||
* Tests stub this to simulate interactive or non-interactive environments.
|
||||
*/
|
||||
isStdinTTY?: boolean;
|
||||
projectionApplier?: FleetAgentCrudCommandDeps['projectionApplier'];
|
||||
reconcileDeps?: FleetReconcilerCommandDeps['reconcileDeps'];
|
||||
}
|
||||
|
||||
interface RawFleetRoster {
|
||||
@@ -530,35 +509,50 @@ export async function generateNorthStarMarkdown(repoRoot?: string): Promise<stri
|
||||
}
|
||||
|
||||
export function generateAgentEnv(roster: FleetRoster, agent: FleetAgent): string {
|
||||
return renderGeneratedAgentEnvironment(generateAgentEnvValues(roster, agent));
|
||||
}
|
||||
|
||||
/**
|
||||
* Compatibility shim for callers that previously merged site-owned data into a
|
||||
* generated file. Local data is validated but never appended to the generated
|
||||
* projection, preventing a local override from becoming hidden authority.
|
||||
*/
|
||||
export function mergeAgentEnv(generatedEnv: string, existingEnv?: string): string {
|
||||
parseAgentEnvironment(generatedEnv, 'generated');
|
||||
if (existingEnv?.trim()) parseAgentEnvironment(existingEnv, 'local');
|
||||
return generatedEnv;
|
||||
}
|
||||
|
||||
function generateAgentEnvValues(
|
||||
roster: FleetRoster,
|
||||
agent: FleetAgent,
|
||||
): Readonly<Record<string, string>> {
|
||||
const workingDirectory = agent.workingDirectory ?? roster.defaults.workingDirectory;
|
||||
return {
|
||||
MOSAIC_AGENT_NAME: agent.name,
|
||||
MOSAIC_AGENT_CLASS: agent.className,
|
||||
MOSAIC_AGENT_RUNTIME: agent.runtime,
|
||||
MOSAIC_AGENT_MODEL: agent.modelHint ?? '',
|
||||
MOSAIC_AGENT_REASONING: agent.reasoningLevel ?? '',
|
||||
MOSAIC_AGENT_TOOL_POLICY: agent.toolPolicy ?? '',
|
||||
MOSAIC_AGENT_WORKDIR: expandHome(workingDirectory),
|
||||
MOSAIC_TMUX_SOCKET: roster.tmux.socketName,
|
||||
};
|
||||
return [
|
||||
`MOSAIC_AGENT_NAME=${shellEnvValue(agent.name)}`,
|
||||
// Per-agent class → start-agent-session.sh / launcher reads this to inject the
|
||||
// matching persona contract for the agent's class (default `worker`).
|
||||
`MOSAIC_AGENT_CLASS=${shellEnvValue(agent.className)}`,
|
||||
`MOSAIC_AGENT_RUNTIME=${shellEnvValue(agent.runtime)}`,
|
||||
// Per-agent model hint → start-agent-session.sh appends `--model <hint>` to
|
||||
// the `mosaic yolo` launch so workers run on the roster's model (e.g. pi on
|
||||
// openai-codex/gpt-5.5:high). Empty when the agent declares no model_hint.
|
||||
`MOSAIC_AGENT_MODEL=${shellEnvValue(agent.modelHint ?? '')}`,
|
||||
...(agent.reasoningLevel !== undefined
|
||||
? [`MOSAIC_AGENT_REASONING=${shellEnvValue(agent.reasoningLevel)}`]
|
||||
: []),
|
||||
...(agent.toolPolicy !== undefined
|
||||
? [`MOSAIC_AGENT_TOOL_POLICY=${shellEnvValue(agent.toolPolicy)}`]
|
||||
: []),
|
||||
`MOSAIC_AGENT_WORKDIR=${shellEnvValue(expandHome(workingDirectory))}`,
|
||||
`MOSAIC_TMUX_SOCKET=${shellEnvValue(roster.tmux.socketName)}`,
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function mergeAgentEnv(generatedEnv: string, existingEnv?: string): string {
|
||||
if (!existingEnv?.trim()) {
|
||||
return generatedEnv;
|
||||
}
|
||||
const generatedKeys = new Set(
|
||||
generatedEnv
|
||||
.split('\n')
|
||||
.map((line) => line.match(/^([A-Za-z_][A-Za-z0-9_]*)=/)?.[1])
|
||||
.filter((key): key is string => key !== undefined),
|
||||
);
|
||||
const preservedLines = existingEnv.split('\n').filter((line) => {
|
||||
if (!line.trim()) {
|
||||
return false;
|
||||
}
|
||||
const key = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=/)?.[1];
|
||||
return key === undefined || !generatedKeys.has(key);
|
||||
});
|
||||
if (preservedLines.length === 0) {
|
||||
return generatedEnv;
|
||||
}
|
||||
return [generatedEnv.trimEnd(), ...preservedLines, ''].join('\n');
|
||||
}
|
||||
|
||||
export function buildFleetServiceCommand(action: FleetServiceAction, agentName?: string): string[] {
|
||||
@@ -1550,12 +1544,8 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
||||
`Fleet roster already exists: ${destination}. Re-run with --force to overwrite.`,
|
||||
);
|
||||
}
|
||||
if (resolve(destination) === resolve(activePaths.rosterPath)) {
|
||||
await writeManagedFleetRoster(activePaths.mosaicHome, destination, content);
|
||||
} else {
|
||||
await mkdir(dirname(destination), { recursive: true });
|
||||
await writeFile(destination, content);
|
||||
}
|
||||
await mkdir(dirname(destination), { recursive: true });
|
||||
await writeFile(destination, content);
|
||||
|
||||
// Guarantee the two-agent floor: exactly one orchestrator AND at least
|
||||
// one enhancer for every profile except the sanctioned no-orchestrator
|
||||
@@ -1612,70 +1602,53 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
||||
cmd
|
||||
.command(`${action} [agent]`)
|
||||
.description(`${action} the fleet holder or one agent`)
|
||||
.option('--expected-generation <number>', 'Authoritative roster generation for roster-v2')
|
||||
.option('--dry-run', 'Plan roster-v2 lifecycle effects without mutation')
|
||||
.action(
|
||||
async (
|
||||
agent: string | undefined,
|
||||
opts: { expectedGeneration?: string; dryRun?: boolean },
|
||||
) => {
|
||||
if (await usesRosterV2ControlPlane(cmd)) {
|
||||
await executeReconcilerCommandJson(
|
||||
cmd,
|
||||
{ runner, mosaicHome: deps.mosaicHome, reconcileDeps: deps.reconcileDeps },
|
||||
action,
|
||||
opts,
|
||||
agent,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const commandOpts = cmd.opts<{ mosaicHome: string; roster?: string }>();
|
||||
const activePaths = resolveFleetPaths(commandOpts.mosaicHome);
|
||||
const roster = await loadRosterForCommand(cmd);
|
||||
if (agent) {
|
||||
getRosterAgent(roster, agent);
|
||||
// Single-agent restart is guarded too: it can race a full restart that
|
||||
// is tearing the shared holder down.
|
||||
if (action === 'restart') {
|
||||
const guard = await acquireRestartLock(activePaths.mosaicHome, sleepFn);
|
||||
try {
|
||||
await runChecked(runner, buildFleetServiceCommand(action, agent));
|
||||
} finally {
|
||||
await guard.release();
|
||||
}
|
||||
return;
|
||||
}
|
||||
await runChecked(runner, buildFleetServiceCommand(action, agent));
|
||||
return;
|
||||
}
|
||||
if (action === 'stop') {
|
||||
await stopFleetBestEffort(
|
||||
runner,
|
||||
roster.agents.map((rosterAgent) => rosterAgent.name),
|
||||
);
|
||||
return;
|
||||
}
|
||||
.action(async (agent?: string) => {
|
||||
const commandOpts = cmd.opts<{ mosaicHome: string; roster?: string }>();
|
||||
const activePaths = resolveFleetPaths(commandOpts.mosaicHome);
|
||||
const roster = await loadRosterForCommand(cmd);
|
||||
if (agent) {
|
||||
getRosterAgent(roster, agent);
|
||||
// Single-agent restart is guarded too: it can race a full restart that
|
||||
// is tearing the shared holder down.
|
||||
if (action === 'restart') {
|
||||
// Serialize the holder+agents teardown/relaunch behind the restart lock
|
||||
// so a re-entrant restart waits for clean shutdown before relaunching,
|
||||
// instead of racing a half-torn-down holder into a tight loop.
|
||||
const guard = await acquireRestartLock(activePaths.mosaicHome, sleepFn);
|
||||
try {
|
||||
await runChecked(runner, buildFleetServiceCommand(action));
|
||||
for (const rosterAgent of roster.agents) {
|
||||
await runChecked(runner, buildFleetServiceCommand(action, rosterAgent.name));
|
||||
}
|
||||
await runChecked(runner, buildFleetServiceCommand(action, agent));
|
||||
} finally {
|
||||
await guard.release();
|
||||
}
|
||||
return;
|
||||
}
|
||||
await runChecked(runner, buildFleetServiceCommand(action));
|
||||
for (const rosterAgent of roster.agents) {
|
||||
await runChecked(runner, buildFleetServiceCommand(action, rosterAgent.name));
|
||||
await runChecked(runner, buildFleetServiceCommand(action, agent));
|
||||
return;
|
||||
}
|
||||
if (action === 'stop') {
|
||||
await stopFleetBestEffort(
|
||||
runner,
|
||||
roster.agents.map((rosterAgent) => rosterAgent.name),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (action === 'restart') {
|
||||
// Serialize the holder+agents teardown/relaunch behind the restart lock
|
||||
// so a re-entrant restart waits for clean shutdown before relaunching,
|
||||
// instead of racing a half-torn-down holder into a tight loop.
|
||||
const guard = await acquireRestartLock(activePaths.mosaicHome, sleepFn);
|
||||
try {
|
||||
await runChecked(runner, buildFleetServiceCommand(action));
|
||||
for (const rosterAgent of roster.agents) {
|
||||
await runChecked(runner, buildFleetServiceCommand(action, rosterAgent.name));
|
||||
}
|
||||
} finally {
|
||||
await guard.release();
|
||||
}
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
await runChecked(runner, buildFleetServiceCommand(action));
|
||||
for (const rosterAgent of roster.agents) {
|
||||
await runChecked(runner, buildFleetServiceCommand(action, rosterAgent.name));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
cmd
|
||||
@@ -1683,16 +1656,6 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
||||
.description('Show fleet holder or agent systemd status')
|
||||
.option('--json', 'Print JSON status')
|
||||
.action(async (agent: string | undefined, opts: { json?: boolean }) => {
|
||||
if (await usesRosterV2ControlPlane(cmd)) {
|
||||
await executeReconcilerCommandJson(
|
||||
cmd,
|
||||
{ runner, mosaicHome: deps.mosaicHome, reconcileDeps: deps.reconcileDeps },
|
||||
'status',
|
||||
{},
|
||||
agent,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (agent) {
|
||||
const roster = await loadRosterForCommand(cmd);
|
||||
getRosterAgent(roster, agent);
|
||||
@@ -1716,15 +1679,6 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
||||
.command('verify')
|
||||
.description('Verify the local canary holder and roster sessions on the isolated socket')
|
||||
.action(async () => {
|
||||
if (await usesRosterV2ControlPlane(cmd)) {
|
||||
await executeReconcilerCommandJson(
|
||||
cmd,
|
||||
{ runner, mosaicHome: deps.mosaicHome, reconcileDeps: deps.reconcileDeps },
|
||||
'verify',
|
||||
{},
|
||||
);
|
||||
return;
|
||||
}
|
||||
const roster = await loadRosterForCommand(cmd);
|
||||
const socketName = roster.tmux.socketName;
|
||||
await runChecked(runner, [
|
||||
@@ -1994,17 +1948,15 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
||||
};
|
||||
|
||||
const updatedRoster = addAgentToRoster(roster, newAgent);
|
||||
const generated = generateAgentEnvValues(updatedRoster, newAgent);
|
||||
// Preflight every deterministic projection, local, legacy, quarantine,
|
||||
// and managed-directory condition before roster authority changes.
|
||||
const preparedProjection = await prepareAgentEnvironmentProjection({
|
||||
mosaicHome: activePaths.mosaicHome,
|
||||
agentEnvDir: activePaths.agentEnvDir,
|
||||
agentName: name,
|
||||
generated,
|
||||
});
|
||||
await writeFile(rosterPath, serializeRosterToYaml(updatedRoster));
|
||||
await applyPreparedAgentEnvironmentProjection(preparedProjection);
|
||||
|
||||
const envPath = join(activePaths.agentEnvDir, `${name}.env`);
|
||||
const existingEnv = (await canRead(envPath)) ? await readFile(envPath, 'utf8') : undefined;
|
||||
await mkdir(activePaths.agentEnvDir, { recursive: true });
|
||||
await writeFile(
|
||||
envPath,
|
||||
mergeAgentEnv(generateAgentEnv(updatedRoster, newAgent), existingEnv),
|
||||
);
|
||||
|
||||
console.log(`Added ${name} (${opts.runtime}/${opts.class}) to the fleet.`);
|
||||
|
||||
@@ -2085,11 +2037,10 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
||||
// Write updated roster
|
||||
await writeFile(rosterPath, serializeRosterToYaml(updatedRoster));
|
||||
|
||||
// Delete the non-authoritative generated projection and heartbeat files
|
||||
// (best-effort, non-fatal). Operator local/quarantine data is retained.
|
||||
// Delete env and heartbeat files (best-effort, non-fatal)
|
||||
if (!opts.keepFiles) {
|
||||
try {
|
||||
await unlink(join(activePaths.agentEnvDir, `${name}.env.generated`));
|
||||
await unlink(join(activePaths.agentEnvDir, `${name}.env`));
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
@@ -2121,15 +2072,6 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
||||
// profile. DRY-RUN by default; --write persists under the same --mosaic-home.
|
||||
registerFleetProvisionCommand(cmd, () => cmd.opts<{ mosaicHome: string }>().mosaicHome);
|
||||
|
||||
// Roster-v2 desired-state mutations belong directly to the fleet control
|
||||
// plane; they do not share the root `mosaic agent` gateway-backed surface.
|
||||
registerFleetAgentCrudCommands(cmd, deps);
|
||||
registerFleetReconcilerCommands(cmd, {
|
||||
runner,
|
||||
mosaicHome: deps.mosaicHome,
|
||||
reconcileDeps: deps.reconcileDeps,
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
|
||||
@@ -2386,17 +2328,16 @@ async function installFleet(cmd: Command, frameworkRoot: string): Promise<void>
|
||||
const activePaths = resolveFleetPaths(cmd.opts<{ mosaicHome: string }>().mosaicHome);
|
||||
assertDefaultMosaicHomeForSystemd(activePaths.mosaicHome);
|
||||
const roster = await loadRosterForCommand(cmd);
|
||||
await ensureFleetHolderIdentity(activePaths.mosaicHome);
|
||||
await mkdir(activePaths.fleetToolsDir, { recursive: true });
|
||||
await mkdir(activePaths.tmuxToolsDir, { recursive: true });
|
||||
await mkdir(activePaths.systemdUserDir, { recursive: true });
|
||||
await mkdir(activePaths.agentEnvDir, { recursive: true });
|
||||
|
||||
const startAgentSessionPath = join(activePaths.fleetToolsDir, 'start-agent-session.sh');
|
||||
const startInteractionServicePath = join(
|
||||
activePaths.fleetToolsDir,
|
||||
'start-interaction-service.sh',
|
||||
);
|
||||
const startTmuxHolderPath = join(activePaths.fleetToolsDir, 'start-tmux-holder.sh');
|
||||
const printInteractionPolicyPath = join(
|
||||
activePaths.fleetToolsDir,
|
||||
'print-interaction-effective-policy.sh',
|
||||
@@ -2406,7 +2347,6 @@ async function installFleet(cmd: Command, frameworkRoot: string): Promise<void>
|
||||
const executableToolPaths = [
|
||||
startAgentSessionPath,
|
||||
startInteractionServicePath,
|
||||
startTmuxHolderPath,
|
||||
printInteractionPolicyPath,
|
||||
sendMessagePath,
|
||||
agentSendPath,
|
||||
@@ -2419,10 +2359,6 @@ async function installFleet(cmd: Command, frameworkRoot: string): Promise<void>
|
||||
join(frameworkRoot, 'tools', 'fleet', 'start-interaction-service.sh'),
|
||||
startInteractionServicePath,
|
||||
);
|
||||
await copyFile(
|
||||
join(frameworkRoot, 'tools', 'fleet', 'start-tmux-holder.sh'),
|
||||
startTmuxHolderPath,
|
||||
);
|
||||
await copyFile(
|
||||
join(frameworkRoot, 'tools', 'fleet', 'print-interaction-effective-policy.sh'),
|
||||
printInteractionPolicyPath,
|
||||
@@ -2446,12 +2382,9 @@ async function installFleet(cmd: Command, frameworkRoot: string): Promise<void>
|
||||
);
|
||||
|
||||
for (const agent of roster.agents) {
|
||||
await writeAgentEnvironmentProjection({
|
||||
mosaicHome: activePaths.mosaicHome,
|
||||
agentEnvDir: activePaths.agentEnvDir,
|
||||
agentName: agent.name,
|
||||
generated: generateAgentEnvValues(roster, agent),
|
||||
});
|
||||
const envPath = join(activePaths.agentEnvDir, `${agent.name}.env`);
|
||||
const existingEnv = (await canRead(envPath)) ? await readFile(envPath, 'utf8') : undefined;
|
||||
await writeFile(envPath, mergeAgentEnv(generateAgentEnv(roster, agent), existingEnv));
|
||||
}
|
||||
|
||||
console.log(`Installed fleet files for ${roster.agents.length} agent(s).`);
|
||||
@@ -2462,20 +2395,6 @@ async function loadRosterForCommand(cmd: Command): Promise<FleetRoster> {
|
||||
return loadFleetRoster(await resolveRosterPath(opts.mosaicHome, opts.roster));
|
||||
}
|
||||
|
||||
/** Routes only a v2 roster to the M3 desired-state control plane; v1 aliases stay compatible. */
|
||||
async function usesRosterV2ControlPlane(cmd: Command): Promise<boolean> {
|
||||
const opts = cmd.opts<{ mosaicHome: string; roster?: string }>();
|
||||
const path = await resolveRosterPath(opts.mosaicHome, opts.roster);
|
||||
const parsed: unknown = YAML.parse(await readFile(path, 'utf8'));
|
||||
return (
|
||||
typeof parsed === 'object' &&
|
||||
parsed !== null &&
|
||||
!Array.isArray(parsed) &&
|
||||
'version' in parsed &&
|
||||
parsed.version === 2
|
||||
);
|
||||
}
|
||||
|
||||
async function loadRosterFromAgentCommand(
|
||||
command: Command,
|
||||
mosaicHomeOverride?: string,
|
||||
@@ -2737,6 +2656,19 @@ function expandHome(path: string): string {
|
||||
return path === '~' || path.startsWith('~/') ? join(homedir(), path.slice(2)) : path;
|
||||
}
|
||||
|
||||
function shellEnvValue(value: string): string {
|
||||
// Empty ⇒ a bare `VAR=` (unambiguous empty in a systemd EnvironmentFile and
|
||||
// when shell-sourced). Quoting it as '' risks a literal two-char value (e.g.
|
||||
// a tmux socket named "''"), which would defeat the default-socket behavior.
|
||||
if (value === '') {
|
||||
return '';
|
||||
}
|
||||
if (/^[A-Za-z0-9_./:=@+-]+$/.test(value)) {
|
||||
return value;
|
||||
}
|
||||
return `'${value.replaceAll("'", "'\"'\"'")}'`;
|
||||
}
|
||||
|
||||
async function stopFleetBestEffort(runner: CommandRunner, agentNames: string[]): Promise<void> {
|
||||
const failures: string[] = [];
|
||||
for (const agentName of agentNames) {
|
||||
@@ -2838,8 +2770,13 @@ export function countEnhancers(roster: FleetRoster): number {
|
||||
}
|
||||
|
||||
/** Valid runtime identifiers for fleet agents. */
|
||||
/** Runtime launchers accepted by `fleet add`; shared with the generated projection validator. */
|
||||
export const VALID_FLEET_RUNTIMES: readonly string[] = GENERATED_AGENT_ENV_SUPPORTED_RUNTIMES;
|
||||
export const VALID_FLEET_RUNTIMES: readonly string[] = [
|
||||
'pi',
|
||||
'claude',
|
||||
'codex',
|
||||
'opencode',
|
||||
'dogfood',
|
||||
];
|
||||
|
||||
/**
|
||||
* Add a new agent to a fleet roster (immutable — returns a new FleetRoster).
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
import { cp, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
SHIPPED_FLEET_ARTIFACT_DISPOSITIONS,
|
||||
validateShippedFleetArtifactDispositions,
|
||||
} from './example-profile-dispositions.js';
|
||||
|
||||
const frameworkFleet = resolve(
|
||||
dirname(fileURLToPath(import.meta.url)),
|
||||
'..',
|
||||
'..',
|
||||
'framework',
|
||||
'fleet',
|
||||
);
|
||||
|
||||
const EXPECTED_DISPOSITIONS = [
|
||||
'examples/coding.yaml:v1-fixture',
|
||||
'examples/general.yaml:v1-fixture',
|
||||
'examples/hybrid.yaml:v1-fixture',
|
||||
'examples/local-canary.yaml:v1-fixture',
|
||||
'examples/minimal.yaml:v1-fixture',
|
||||
'examples/operator-interaction.yaml:v1-fixture',
|
||||
'examples/research.yaml:v1-fixture',
|
||||
'profiles/business.yaml:canonical-profile',
|
||||
'profiles/marketing.yaml:canonical-profile',
|
||||
'profiles/personal-assistant.yaml:canonical-profile',
|
||||
'profiles/research.yaml:canonical-profile',
|
||||
'profiles/software-delivery.yaml:canonical-profile',
|
||||
'services/operator-interaction.yaml:canonical-service-policy',
|
||||
];
|
||||
|
||||
const declared = SHIPPED_FLEET_ARTIFACT_DISPOSITIONS.map(
|
||||
({ path, disposition }): string => `${path}:${disposition}`,
|
||||
);
|
||||
|
||||
describe('shipped fleet example/profile/service disposition validation', (): void => {
|
||||
it('enumerates every inventory artifact with an explicit executable disposition', (): void => {
|
||||
expect(declared).toEqual(EXPECTED_DISPOSITIONS);
|
||||
});
|
||||
|
||||
it('validates every shipped artifact through its declared v1 or canonical path', async (): Promise<void> => {
|
||||
const results = await validateShippedFleetArtifactDispositions({ frameworkFleet });
|
||||
|
||||
expect(results.map(({ path, disposition }): string => `${path}:${disposition}`)).toEqual(
|
||||
EXPECTED_DISPOSITIONS,
|
||||
);
|
||||
expect(results.filter(({ disposition }): boolean => disposition === 'v1-fixture')).toHaveLength(
|
||||
7,
|
||||
);
|
||||
expect(
|
||||
results.filter(({ disposition }): boolean => disposition === 'canonical-profile'),
|
||||
).toHaveLength(5);
|
||||
expect(
|
||||
results.find(({ path }): boolean => path === 'services/operator-interaction.yaml'),
|
||||
).toMatchObject({
|
||||
disposition: 'canonical-service-policy',
|
||||
});
|
||||
});
|
||||
|
||||
let temporaryFleet: string | undefined;
|
||||
afterEach(async (): Promise<void> => {
|
||||
if (temporaryFleet) await rm(temporaryFleet, { recursive: true, force: true });
|
||||
temporaryFleet = undefined;
|
||||
});
|
||||
|
||||
it('fails closed when a shipped artifact lacks a declared disposition', async (): Promise<void> => {
|
||||
temporaryFleet = await mkdtemp(join(tmpdir(), 'mosaic-dispositions-'));
|
||||
await cp(frameworkFleet, temporaryFleet, { recursive: true });
|
||||
await writeFile(join(temporaryFleet, 'examples', 'undeclared.yaml'), 'version: 1\n');
|
||||
|
||||
await expect(
|
||||
validateShippedFleetArtifactDispositions({ frameworkFleet: temporaryFleet }),
|
||||
).rejects.toThrow(/undeclared shipped fleet artifact.*examples\/undeclared.yaml/i);
|
||||
});
|
||||
|
||||
it('rejects a v1 fixture when its explicit version declaration is removed', async (): Promise<void> => {
|
||||
temporaryFleet = await mkdtemp(join(tmpdir(), 'mosaic-dispositions-'));
|
||||
await cp(frameworkFleet, temporaryFleet, { recursive: true });
|
||||
const fixturePath = join(temporaryFleet, 'examples', 'coding.yaml');
|
||||
const fixture = await readFile(fixturePath, 'utf8');
|
||||
await writeFile(fixturePath, fixture.replace(/^version: 1\n/, ''));
|
||||
|
||||
await expect(
|
||||
validateShippedFleetArtifactDispositions({ frameworkFleet: temporaryFleet }),
|
||||
).rejects.toThrow(/Fleet roster version must be 1/);
|
||||
});
|
||||
});
|
||||
@@ -1,121 +0,0 @@
|
||||
import { readdir } from 'node:fs/promises';
|
||||
import { basename, join } from 'node:path';
|
||||
import { loadFleetRoster } from '../commands/fleet.js';
|
||||
import { loadProfiles } from '../commands/fleet-profiles.js';
|
||||
import {
|
||||
provisionInteractionService,
|
||||
readInteractionServiceProfile,
|
||||
} from './interaction-service-profile.js';
|
||||
|
||||
export type FleetArtifactDisposition =
|
||||
| 'v1-fixture'
|
||||
| 'canonical-profile'
|
||||
| 'canonical-service-policy';
|
||||
|
||||
export interface ShippedFleetArtifactDisposition {
|
||||
readonly path: string;
|
||||
readonly disposition: FleetArtifactDisposition;
|
||||
}
|
||||
|
||||
export interface ValidateShippedFleetArtifactDispositionsOptions {
|
||||
readonly frameworkFleet: string;
|
||||
readonly rolesDir?: string;
|
||||
readonly overrideDir?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The M0 inventory in executable form. Every shipped fleet YAML asset is either
|
||||
* a deliberately retained v1 fixture or validated through its canonical loader.
|
||||
*/
|
||||
export const SHIPPED_FLEET_ARTIFACT_DISPOSITIONS: readonly ShippedFleetArtifactDisposition[] = [
|
||||
{ path: 'examples/coding.yaml', disposition: 'v1-fixture' },
|
||||
{ path: 'examples/general.yaml', disposition: 'v1-fixture' },
|
||||
{ path: 'examples/hybrid.yaml', disposition: 'v1-fixture' },
|
||||
{ path: 'examples/local-canary.yaml', disposition: 'v1-fixture' },
|
||||
{ path: 'examples/minimal.yaml', disposition: 'v1-fixture' },
|
||||
{ path: 'examples/operator-interaction.yaml', disposition: 'v1-fixture' },
|
||||
{ path: 'examples/research.yaml', disposition: 'v1-fixture' },
|
||||
{ path: 'profiles/business.yaml', disposition: 'canonical-profile' },
|
||||
{ path: 'profiles/marketing.yaml', disposition: 'canonical-profile' },
|
||||
{ path: 'profiles/personal-assistant.yaml', disposition: 'canonical-profile' },
|
||||
{ path: 'profiles/research.yaml', disposition: 'canonical-profile' },
|
||||
{ path: 'profiles/software-delivery.yaml', disposition: 'canonical-profile' },
|
||||
{ path: 'services/operator-interaction.yaml', disposition: 'canonical-service-policy' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Fail closed when a fleet YAML asset is added or removed without a disposition.
|
||||
* This keeps legacy v1 compatibility explicit instead of silently accepting new
|
||||
* unresolved classes outside the shared resolver.
|
||||
*/
|
||||
export async function validateShippedFleetArtifactDispositions(
|
||||
options: ValidateShippedFleetArtifactDispositionsOptions,
|
||||
): Promise<readonly ShippedFleetArtifactDisposition[]> {
|
||||
await assertEveryShippedArtifactIsDeclared(options.frameworkFleet);
|
||||
|
||||
const examples = SHIPPED_FLEET_ARTIFACT_DISPOSITIONS.filter(
|
||||
({ disposition }): boolean => disposition === 'v1-fixture',
|
||||
);
|
||||
for (const artifact of examples) {
|
||||
const roster = await loadFleetRoster(join(options.frameworkFleet, artifact.path));
|
||||
if (roster.version !== 1) {
|
||||
throw new Error(`v1 fixture ${artifact.path} must declare version: 1`);
|
||||
}
|
||||
}
|
||||
|
||||
const profilesDir = join(options.frameworkFleet, 'profiles');
|
||||
const profiles = await loadProfiles({
|
||||
profilesDir,
|
||||
rolesDir: options.rolesDir ?? join(options.frameworkFleet, 'roles'),
|
||||
overrideDir: options.overrideDir ?? join(options.frameworkFleet, 'roles.local'),
|
||||
});
|
||||
const declaredProfiles = SHIPPED_FLEET_ARTIFACT_DISPOSITIONS.filter(
|
||||
({ disposition }): boolean => disposition === 'canonical-profile',
|
||||
).map(({ path }): string => basename(path, '.yaml'));
|
||||
const resolvedProfiles = new Set(profiles.map(({ id }): string => id));
|
||||
for (const profileId of declaredProfiles) {
|
||||
if (!resolvedProfiles.has(profileId)) {
|
||||
throw new Error(`declared canonical profile ${profileId} did not resolve`);
|
||||
}
|
||||
}
|
||||
|
||||
const servicePolicies = SHIPPED_FLEET_ARTIFACT_DISPOSITIONS.filter(
|
||||
({ disposition }): boolean => disposition === 'canonical-service-policy',
|
||||
);
|
||||
for (const artifact of servicePolicies) {
|
||||
const serviceProfile = await readInteractionServiceProfile(
|
||||
join(options.frameworkFleet, artifact.path),
|
||||
);
|
||||
provisionInteractionService(serviceProfile, { agentName: 'interaction-example' });
|
||||
}
|
||||
|
||||
return SHIPPED_FLEET_ARTIFACT_DISPOSITIONS;
|
||||
}
|
||||
|
||||
async function assertEveryShippedArtifactIsDeclared(frameworkFleet: string): Promise<void> {
|
||||
const declared = new Set(SHIPPED_FLEET_ARTIFACT_DISPOSITIONS.map(({ path }): string => path));
|
||||
const shipped = await listShippedFleetArtifactPaths(frameworkFleet);
|
||||
|
||||
for (const path of shipped) {
|
||||
if (!declared.has(path)) {
|
||||
throw new Error(`undeclared shipped fleet artifact: ${path}`);
|
||||
}
|
||||
}
|
||||
for (const path of declared) {
|
||||
if (!shipped.has(path)) {
|
||||
throw new Error(`declared shipped fleet artifact is missing: ${path}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function listShippedFleetArtifactPaths(frameworkFleet: string): Promise<Set<string>> {
|
||||
const directories = ['examples', 'profiles', 'services'];
|
||||
const paths = new Set<string>();
|
||||
for (const directory of directories) {
|
||||
const files = await readdir(join(frameworkFleet, directory));
|
||||
for (const file of files) {
|
||||
if (file.endsWith('.yaml') || file.endsWith('.yml')) paths.add(`${directory}/${file}`);
|
||||
}
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
@@ -1,220 +0,0 @@
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
FleetAgentMutationError,
|
||||
executeFleetAgentMutation,
|
||||
planFleetAgentMutation,
|
||||
type FleetAgentMutationRequest,
|
||||
} from './fleet-agent-crud.js';
|
||||
import { parseRosterV2, renderRosterV2Yaml } from './roster-v2.js';
|
||||
|
||||
const roster = parseRosterV2(`
|
||||
version: 2
|
||||
generation: 7
|
||||
transport: tmux
|
||||
tmux:
|
||||
socket_name: mosaic-fleet
|
||||
holder_session: _holder
|
||||
defaults:
|
||||
working_directory: /srv/mosaic
|
||||
runtime: pi
|
||||
runtimes:
|
||||
pi:
|
||||
reset_command: /new
|
||||
agents:
|
||||
- name: orchestrator
|
||||
alias: Orchestrator
|
||||
class: orchestrator
|
||||
runtime: pi
|
||||
provider: openai
|
||||
model: gpt-5.6-sol
|
||||
reasoning: high
|
||||
tool_policy: orchestrator
|
||||
working_directory: /srv/mosaic
|
||||
persistent_persona: true
|
||||
reset_between_tasks: false
|
||||
lifecycle:
|
||||
enabled: true
|
||||
desired_state: stopped
|
||||
launch:
|
||||
yolo: true
|
||||
`);
|
||||
|
||||
function createRequest(): FleetAgentMutationRequest {
|
||||
return {
|
||||
operation: 'create',
|
||||
expectedGeneration: 7,
|
||||
agent: {
|
||||
name: 'coder0',
|
||||
alias: 'Coder 0',
|
||||
className: 'code',
|
||||
runtime: 'pi',
|
||||
provider: 'openai',
|
||||
model: 'gpt-5.6-sol',
|
||||
reasoning: 'high',
|
||||
toolPolicy: 'code',
|
||||
workingDirectory: '/srv/mosaic',
|
||||
persistentPersona: false,
|
||||
resetBetweenTasks: true,
|
||||
launch: { yolo: true },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
let cleanup: string | undefined;
|
||||
afterEach(async (): Promise<void> => {
|
||||
if (cleanup) await rm(cleanup, { recursive: true, force: true });
|
||||
cleanup = undefined;
|
||||
});
|
||||
|
||||
async function semanticDirs(): Promise<{ rolesDir: string; overrideDir: string }> {
|
||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-fleet-crud-'));
|
||||
const rolesDir = join(cleanup, 'roles');
|
||||
const overrideDir = join(cleanup, 'roles.local');
|
||||
await mkdir(rolesDir, { recursive: true });
|
||||
await mkdir(overrideDir, { recursive: true });
|
||||
for (const klass of ['orchestrator', 'code']) {
|
||||
await writeFile(join(rolesDir, `${klass}.md`), `# ${klass}\n\n(\`class: ${klass}\`)\n`);
|
||||
}
|
||||
return { rolesDir, overrideDir };
|
||||
}
|
||||
|
||||
describe('fleet agent CRUD plan', (): void => {
|
||||
it('creates stopped-by-default desired state and a deterministic generation plan', (): void => {
|
||||
const plan = planFleetAgentMutation(roster, createRequest());
|
||||
|
||||
expect(plan).toMatchObject({
|
||||
operation: 'create',
|
||||
currentGeneration: 7,
|
||||
nextGeneration: 8,
|
||||
changed: true,
|
||||
agent: { name: 'coder0', lifecycle: { enabled: true, desiredState: 'stopped' } },
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a stale generation before proposing effects', (): void => {
|
||||
expect((): void => {
|
||||
planFleetAgentMutation(roster, { ...createRequest(), expectedGeneration: 6 });
|
||||
}).toThrow(FleetAgentMutationError);
|
||||
});
|
||||
|
||||
it('treats an equivalent retry as an idempotent no-op', (): void => {
|
||||
const created = planFleetAgentMutation(roster, createRequest()).roster;
|
||||
const retried = planFleetAgentMutation(created, {
|
||||
...createRequest(),
|
||||
expectedGeneration: created.generation,
|
||||
});
|
||||
|
||||
expect(retried).toMatchObject({ changed: false, currentGeneration: 8, nextGeneration: 8 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('fleet agent CRUD execution', (): void => {
|
||||
it('keeps roster and projections unchanged for dry-run', async (): Promise<void> => {
|
||||
const dirs = await semanticDirs();
|
||||
const home = join(cleanup!, 'mosaic');
|
||||
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
||||
await mkdir(join(home, 'fleet', 'agents'), { recursive: true, mode: 0o700 });
|
||||
await writeFile(rosterPath, 'before\n', { mode: 0o600 });
|
||||
|
||||
const result = await executeFleetAgentMutation({
|
||||
roster,
|
||||
request: createRequest(),
|
||||
mosaicHome: home,
|
||||
rosterPath,
|
||||
agentEnvDir: join(home, 'fleet', 'agents'),
|
||||
rolesDir: dirs.rolesDir,
|
||||
overrideDir: dirs.overrideDir,
|
||||
dryRun: true,
|
||||
});
|
||||
|
||||
expect(result.applied).toBe(false);
|
||||
expect(await readFile(rosterPath, 'utf8')).toBe('before\n');
|
||||
await expect(
|
||||
readFile(join(home, 'fleet', 'agents', 'coder0.env.generated'), 'utf8'),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('fails closed when another writer owns the mutation lock', async (): Promise<void> => {
|
||||
const dirs = await semanticDirs();
|
||||
const home = join(cleanup!, 'mosaic');
|
||||
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
||||
await mkdir(join(home, 'fleet', 'agents'), { recursive: true, mode: 0o700 });
|
||||
await writeFile(rosterPath, renderRosterV2Yaml(roster), { mode: 0o600 });
|
||||
await writeFile(`${rosterPath}.mutation.lock`, 'owned\n', { mode: 0o600 });
|
||||
|
||||
await expect(
|
||||
executeFleetAgentMutation({
|
||||
roster,
|
||||
request: createRequest(),
|
||||
mosaicHome: home,
|
||||
rosterPath,
|
||||
agentEnvDir: join(home, 'fleet', 'agents'),
|
||||
rolesDir: dirs.rolesDir,
|
||||
overrideDir: dirs.overrideDir,
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 'concurrent-mutation' });
|
||||
expect(await readFile(rosterPath, 'utf8')).toBe(renderRosterV2Yaml(roster));
|
||||
});
|
||||
|
||||
it('deletes only the removed agent generated projection when it is stale or absent', async (): Promise<void> => {
|
||||
const dirs = await semanticDirs();
|
||||
const home = join(cleanup!, 'mosaic');
|
||||
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
||||
const agentEnvDir = join(home, 'fleet', 'agents');
|
||||
const created = planFleetAgentMutation(roster, createRequest()).roster;
|
||||
await mkdir(agentEnvDir, { recursive: true, mode: 0o700 });
|
||||
await writeFile(rosterPath, renderRosterV2Yaml(created), { mode: 0o600 });
|
||||
await writeFile(join(agentEnvDir, 'coder0.env.local'), 'MOSAIC_RUNTIME_BIN=/usr/bin/pi\n', {
|
||||
mode: 0o600,
|
||||
});
|
||||
|
||||
const result = await executeFleetAgentMutation({
|
||||
roster: created,
|
||||
request: { operation: 'delete', expectedGeneration: 8, name: 'coder0' },
|
||||
mosaicHome: home,
|
||||
rosterPath,
|
||||
agentEnvDir,
|
||||
rolesDir: dirs.rolesDir,
|
||||
overrideDir: dirs.overrideDir,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ applied: true, plan: { nextGeneration: 9 } });
|
||||
await expect(readFile(join(agentEnvDir, 'coder0.env.generated'), 'utf8')).rejects.toThrow();
|
||||
expect(await readFile(join(agentEnvDir, 'coder0.env.local'), 'utf8')).toBe(
|
||||
'MOSAIC_RUNTIME_BIN=/usr/bin/pi\n',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns redacted projection recovery after the authoritative roster write', async (): Promise<void> => {
|
||||
const dirs = await semanticDirs();
|
||||
const home = join(cleanup!, 'mosaic');
|
||||
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
||||
await mkdir(join(home, 'fleet', 'agents'), { recursive: true, mode: 0o700 });
|
||||
await writeFile(rosterPath, renderRosterV2Yaml(roster), { mode: 0o600 });
|
||||
|
||||
const result = await executeFleetAgentMutation({
|
||||
roster,
|
||||
request: createRequest(),
|
||||
mosaicHome: home,
|
||||
rosterPath,
|
||||
agentEnvDir: join(home, 'fleet', 'agents'),
|
||||
rolesDir: dirs.rolesDir,
|
||||
overrideDir: dirs.overrideDir,
|
||||
projectionApplier: async (): Promise<void> => {
|
||||
throw new Error('MOSAIC_AGENT_COMMAND=must-not-leak');
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
applied: false,
|
||||
authoritativeRoster: 'committed',
|
||||
projections: 'incomplete',
|
||||
recovery: { code: 'projection-apply-failed', action: 'regenerate-projections-from-roster' },
|
||||
});
|
||||
expect(JSON.stringify(result)).not.toContain('must-not-leak');
|
||||
expect(parseRosterV2(await readFile(rosterPath, 'utf8'), 'yaml').generation).toBe(8);
|
||||
});
|
||||
});
|
||||
@@ -1,397 +0,0 @@
|
||||
import { open, readFile, unlink } from 'node:fs/promises';
|
||||
import { dirname, join } from 'node:path';
|
||||
import {
|
||||
applyPreparedAgentEnvironmentProjection,
|
||||
prepareAgentGeneratedProjectionDeletion,
|
||||
prepareAgentEnvironmentProjection,
|
||||
type PreparedAgentEnvironmentProjection,
|
||||
writeManagedFleetRoster,
|
||||
} from './generated-env-boundary.js';
|
||||
import {
|
||||
parseRosterV2,
|
||||
renderRosterV2Yaml,
|
||||
validateRosterV2Semantics,
|
||||
type FleetRosterV2,
|
||||
type FleetRosterV2Agent,
|
||||
type FleetRosterV2Launch,
|
||||
type FleetRosterV2Lifecycle,
|
||||
type RosterV2ReasoningLevel,
|
||||
type RosterV2RuntimeName,
|
||||
} from './roster-v2.js';
|
||||
|
||||
export type FleetAgentMutationOperation = 'create' | 'update' | 'delete';
|
||||
|
||||
export interface FleetAgentMutationAgent {
|
||||
readonly name: string;
|
||||
readonly alias: string;
|
||||
readonly className: string;
|
||||
readonly runtime: RosterV2RuntimeName;
|
||||
readonly provider: string;
|
||||
readonly model: string;
|
||||
readonly reasoning: RosterV2ReasoningLevel;
|
||||
readonly toolPolicy: string;
|
||||
readonly workingDirectory: string;
|
||||
readonly persistentPersona: boolean;
|
||||
readonly resetBetweenTasks: boolean;
|
||||
readonly launch: FleetRosterV2Launch;
|
||||
}
|
||||
|
||||
export interface FleetAgentMutationRequest {
|
||||
readonly operation: FleetAgentMutationOperation;
|
||||
readonly expectedGeneration: number;
|
||||
readonly name?: string;
|
||||
readonly agent?: FleetAgentMutationAgent;
|
||||
readonly persistedStart?: boolean;
|
||||
}
|
||||
|
||||
export interface FleetAgentMutationPlan {
|
||||
readonly operation: FleetAgentMutationOperation;
|
||||
readonly currentGeneration: number;
|
||||
readonly nextGeneration: number;
|
||||
readonly changed: boolean;
|
||||
readonly roster: FleetRosterV2;
|
||||
readonly agent?: FleetRosterV2Agent;
|
||||
}
|
||||
|
||||
export type FleetAgentMutationAuthoritativeRosterState = 'unchanged' | 'committed';
|
||||
export type FleetAgentMutationProjectionState = 'not-applied' | 'complete' | 'incomplete';
|
||||
|
||||
export interface FleetAgentMutationResult {
|
||||
/** True only when every requested roster and projection write completed. */
|
||||
readonly applied: boolean;
|
||||
/** Whether the authoritative roster changed on disk. */
|
||||
readonly authoritativeRoster: FleetAgentMutationAuthoritativeRosterState;
|
||||
/** Whether derived projections were skipped, complete, or require recovery. */
|
||||
readonly projections: FleetAgentMutationProjectionState;
|
||||
readonly plan: FleetAgentMutationPlan;
|
||||
readonly recovery?: FleetAgentMutationRecovery;
|
||||
}
|
||||
|
||||
export interface FleetAgentMutationRecovery {
|
||||
readonly code: 'projection-apply-failed';
|
||||
readonly rosterPath: string;
|
||||
readonly action: 'regenerate-projections-from-roster';
|
||||
}
|
||||
|
||||
export interface FleetAgentMutationOptions {
|
||||
readonly roster: FleetRosterV2;
|
||||
readonly request: FleetAgentMutationRequest;
|
||||
readonly mosaicHome: string;
|
||||
readonly rosterPath: string;
|
||||
readonly agentEnvDir: string;
|
||||
readonly rolesDir: string;
|
||||
readonly overrideDir: string;
|
||||
readonly dryRun?: boolean;
|
||||
readonly projectionApplier?: (prepared: PreparedAgentEnvironmentProjection) => Promise<unknown>;
|
||||
}
|
||||
|
||||
export class FleetAgentMutationError extends Error {
|
||||
constructor(
|
||||
readonly code:
|
||||
| 'stale-generation'
|
||||
| 'agent-conflict'
|
||||
| 'agent-not-found'
|
||||
| 'invalid-request'
|
||||
| 'concurrent-mutation'
|
||||
| 'projection-apply-failed',
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = FleetAgentMutationError.name;
|
||||
}
|
||||
}
|
||||
|
||||
/** Plans a generation-guarded desired-state mutation without writing any file. */
|
||||
export function planFleetAgentMutation(
|
||||
roster: FleetRosterV2,
|
||||
request: FleetAgentMutationRequest,
|
||||
): FleetAgentMutationPlan {
|
||||
if (request.expectedGeneration !== roster.generation) {
|
||||
throw new FleetAgentMutationError(
|
||||
'stale-generation',
|
||||
`Expected roster generation ${request.expectedGeneration}; current generation is ${roster.generation}.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (request.operation === 'create') return planCreate(roster, request);
|
||||
if (request.operation === 'update') return planUpdate(roster, request);
|
||||
return planDelete(roster, request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the complete proposed roster and its deterministic projections before
|
||||
* changing the roster authority. The only post-roster write is a derived projection.
|
||||
*/
|
||||
export async function executeFleetAgentMutation(
|
||||
options: FleetAgentMutationOptions,
|
||||
): Promise<FleetAgentMutationResult> {
|
||||
const plan = planFleetAgentMutation(options.roster, options.request);
|
||||
await validateRosterV2Semantics(plan.roster, {
|
||||
rolesDir: options.rolesDir,
|
||||
overrideDir: options.overrideDir,
|
||||
});
|
||||
const prepared = await prepareProjections(plan.roster, options.mosaicHome, options.agentEnvDir);
|
||||
if (plan.operation === 'delete' && plan.agent) {
|
||||
// Delete validates only the exact generated projection. Local overrides,
|
||||
// legacy input, and quarantine records are retained operator state.
|
||||
await prepareAgentGeneratedProjectionDeletion({
|
||||
mosaicHome: options.mosaicHome,
|
||||
agentEnvDir: options.agentEnvDir,
|
||||
agentName: plan.agent.name,
|
||||
});
|
||||
}
|
||||
|
||||
if (options.dryRun === true || !plan.changed) {
|
||||
return { applied: false, authoritativeRoster: 'unchanged', projections: 'not-applied', plan };
|
||||
}
|
||||
|
||||
const lockPath = `${options.rosterPath}.mutation.lock`;
|
||||
const release = await acquireMutationLock(lockPath);
|
||||
try {
|
||||
const persisted = parseRosterV2(await readFile(options.rosterPath, 'utf8'), 'yaml');
|
||||
if (persisted.generation !== options.roster.generation) {
|
||||
throw new FleetAgentMutationError(
|
||||
'stale-generation',
|
||||
`Expected roster generation ${options.roster.generation}; current generation is ${persisted.generation}.`,
|
||||
);
|
||||
}
|
||||
|
||||
await writeManagedFleetRoster(
|
||||
options.mosaicHome,
|
||||
options.rosterPath,
|
||||
renderRosterV2Yaml(plan.roster),
|
||||
);
|
||||
const apply = options.projectionApplier ?? applyPreparedAgentEnvironmentProjection;
|
||||
try {
|
||||
for (const projection of prepared) await apply(projection);
|
||||
if (plan.operation === 'delete' && plan.agent) {
|
||||
await prepareAgentGeneratedProjectionDeletion({
|
||||
mosaicHome: options.mosaicHome,
|
||||
agentEnvDir: options.agentEnvDir,
|
||||
agentName: plan.agent.name,
|
||||
});
|
||||
await removeGeneratedProjection(options.agentEnvDir, plan.agent.name);
|
||||
}
|
||||
} catch {
|
||||
throw new FleetAgentMutationError(
|
||||
'projection-apply-failed',
|
||||
`Projection application failed after roster write; regenerate projections from ${options.rosterPath}.`,
|
||||
);
|
||||
}
|
||||
return { applied: true, authoritativeRoster: 'committed', projections: 'complete', plan };
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof FleetAgentMutationError && error.code === 'projection-apply-failed') {
|
||||
return {
|
||||
applied: false,
|
||||
authoritativeRoster: 'committed',
|
||||
projections: 'incomplete',
|
||||
plan,
|
||||
recovery: {
|
||||
code: 'projection-apply-failed',
|
||||
rosterPath: options.rosterPath,
|
||||
action: 'regenerate-projections-from-roster',
|
||||
},
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
}
|
||||
|
||||
function planCreate(
|
||||
roster: FleetRosterV2,
|
||||
request: FleetAgentMutationRequest,
|
||||
): FleetAgentMutationPlan {
|
||||
if (!request.agent)
|
||||
throw new FleetAgentMutationError('invalid-request', 'Create requires an agent.');
|
||||
const existing = roster.agents.find(
|
||||
(agent: FleetRosterV2Agent): boolean => agent.name === request.agent?.name,
|
||||
);
|
||||
const created = toRosterAgent(request.agent, request.persistedStart === true);
|
||||
if (existing) {
|
||||
if (sameAgent(existing, created)) return unchangedPlan(roster, request.operation, existing);
|
||||
throw new FleetAgentMutationError('agent-conflict', `Agent "${created.name}" already exists.`);
|
||||
}
|
||||
return changedPlan(roster, request.operation, [...roster.agents, created], created);
|
||||
}
|
||||
|
||||
function planUpdate(
|
||||
roster: FleetRosterV2,
|
||||
request: FleetAgentMutationRequest,
|
||||
): FleetAgentMutationPlan {
|
||||
if (!request.name || !request.agent) {
|
||||
throw new FleetAgentMutationError('invalid-request', 'Update requires name and agent.');
|
||||
}
|
||||
const existing = roster.agents.find(
|
||||
(agent: FleetRosterV2Agent): boolean => agent.name === request.name,
|
||||
);
|
||||
if (!existing)
|
||||
throw new FleetAgentMutationError(
|
||||
'agent-not-found',
|
||||
`Agent "${request.name}" is not in roster.`,
|
||||
);
|
||||
if (request.agent.name !== request.name) {
|
||||
throw new FleetAgentMutationError(
|
||||
'invalid-request',
|
||||
'Agent names are immutable during update.',
|
||||
);
|
||||
}
|
||||
const updated = {
|
||||
...toRosterAgent(request.agent, existing.lifecycle.desiredState === 'running'),
|
||||
lifecycle: existing.lifecycle,
|
||||
};
|
||||
if (sameAgent(existing, updated)) return unchangedPlan(roster, request.operation, existing);
|
||||
return changedPlan(
|
||||
roster,
|
||||
request.operation,
|
||||
roster.agents.map(
|
||||
(agent: FleetRosterV2Agent): FleetRosterV2Agent =>
|
||||
agent.name === request.name ? updated : agent,
|
||||
),
|
||||
updated,
|
||||
);
|
||||
}
|
||||
|
||||
function planDelete(
|
||||
roster: FleetRosterV2,
|
||||
request: FleetAgentMutationRequest,
|
||||
): FleetAgentMutationPlan {
|
||||
if (!request.name) throw new FleetAgentMutationError('invalid-request', 'Delete requires name.');
|
||||
const existing = roster.agents.find(
|
||||
(agent: FleetRosterV2Agent): boolean => agent.name === request.name,
|
||||
);
|
||||
if (!existing) return unchangedPlan(roster, request.operation);
|
||||
return changedPlan(
|
||||
roster,
|
||||
request.operation,
|
||||
roster.agents.filter((agent: FleetRosterV2Agent): boolean => agent.name !== request.name),
|
||||
existing,
|
||||
);
|
||||
}
|
||||
|
||||
function toRosterAgent(
|
||||
input: FleetAgentMutationAgent,
|
||||
persistedStart: boolean,
|
||||
): FleetRosterV2Agent {
|
||||
const lifecycle: FleetRosterV2Lifecycle = {
|
||||
enabled: true,
|
||||
desiredState: persistedStart ? 'running' : 'stopped',
|
||||
};
|
||||
return { ...input, lifecycle };
|
||||
}
|
||||
|
||||
function changedPlan(
|
||||
roster: FleetRosterV2,
|
||||
operation: FleetAgentMutationOperation,
|
||||
agents: readonly FleetRosterV2Agent[],
|
||||
agent?: FleetRosterV2Agent,
|
||||
): FleetAgentMutationPlan {
|
||||
const proposed = { ...roster, generation: roster.generation + 1, agents };
|
||||
const normalized = parseRosterV2(renderRosterV2Yaml(proposed), 'yaml');
|
||||
return {
|
||||
operation,
|
||||
currentGeneration: roster.generation,
|
||||
nextGeneration: normalized.generation,
|
||||
changed: true,
|
||||
roster: normalized,
|
||||
...(agent ? { agent } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function unchangedPlan(
|
||||
roster: FleetRosterV2,
|
||||
operation: FleetAgentMutationOperation,
|
||||
agent?: FleetRosterV2Agent,
|
||||
): FleetAgentMutationPlan {
|
||||
return {
|
||||
operation,
|
||||
currentGeneration: roster.generation,
|
||||
nextGeneration: roster.generation,
|
||||
changed: false,
|
||||
roster,
|
||||
...(agent ? { agent } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function sameAgent(left: FleetRosterV2Agent, right: FleetRosterV2Agent): boolean {
|
||||
return (
|
||||
left.name === right.name &&
|
||||
left.alias === right.alias &&
|
||||
left.className === right.className &&
|
||||
left.runtime === right.runtime &&
|
||||
left.provider === right.provider &&
|
||||
left.model === right.model &&
|
||||
left.reasoning === right.reasoning &&
|
||||
left.toolPolicy === right.toolPolicy &&
|
||||
left.workingDirectory === right.workingDirectory &&
|
||||
left.persistentPersona === right.persistentPersona &&
|
||||
left.resetBetweenTasks === right.resetBetweenTasks &&
|
||||
left.launch.yolo === right.launch.yolo &&
|
||||
left.lifecycle.enabled === right.lifecycle.enabled &&
|
||||
left.lifecycle.desiredState === right.lifecycle.desiredState
|
||||
);
|
||||
}
|
||||
|
||||
async function prepareProjections(
|
||||
roster: FleetRosterV2,
|
||||
mosaicHome: string,
|
||||
agentEnvDir: string,
|
||||
): Promise<readonly PreparedAgentEnvironmentProjection[]> {
|
||||
const projections: PreparedAgentEnvironmentProjection[] = [];
|
||||
for (const agent of roster.agents) {
|
||||
projections.push(
|
||||
await prepareAgentEnvironmentProjection({
|
||||
mosaicHome,
|
||||
agentEnvDir,
|
||||
agentName: agent.name,
|
||||
generated: generatedValues(roster, agent),
|
||||
}),
|
||||
);
|
||||
}
|
||||
return projections;
|
||||
}
|
||||
|
||||
function generatedValues(
|
||||
roster: FleetRosterV2,
|
||||
agent: FleetRosterV2Agent,
|
||||
): Readonly<Record<string, string>> {
|
||||
return {
|
||||
MOSAIC_AGENT_NAME: agent.name,
|
||||
MOSAIC_AGENT_CLASS: agent.className,
|
||||
MOSAIC_AGENT_RUNTIME: agent.runtime,
|
||||
MOSAIC_AGENT_MODEL: agent.model,
|
||||
MOSAIC_AGENT_REASONING: agent.reasoning,
|
||||
MOSAIC_AGENT_TOOL_POLICY: agent.toolPolicy,
|
||||
MOSAIC_AGENT_WORKDIR: agent.workingDirectory,
|
||||
MOSAIC_TMUX_SOCKET: roster.tmux.socketName,
|
||||
};
|
||||
}
|
||||
|
||||
async function removeGeneratedProjection(agentEnvDir: string, agentName: string): Promise<void> {
|
||||
try {
|
||||
await unlink(join(agentEnvDir, `${agentName}.env.generated`));
|
||||
} catch (error: unknown) {
|
||||
if (isMissingFile(error)) return;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function isMissingFile(error: unknown): boolean {
|
||||
return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT';
|
||||
}
|
||||
|
||||
async function acquireMutationLock(lockPath: string): Promise<() => Promise<void>> {
|
||||
try {
|
||||
const handle = await open(lockPath, 'wx', 0o600);
|
||||
await handle.close();
|
||||
} catch {
|
||||
throw new FleetAgentMutationError(
|
||||
'concurrent-mutation',
|
||||
`Another roster mutation is in progress for ${dirname(lockPath)}.`,
|
||||
);
|
||||
}
|
||||
return async (): Promise<void> => {
|
||||
await unlink(lockPath).catch((): void => {});
|
||||
};
|
||||
}
|
||||
@@ -1,487 +0,0 @@
|
||||
import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Command } from 'commander';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { registerFleetCommand, type CommandResult, type CommandRunner } from '../commands/fleet.js';
|
||||
import {
|
||||
executeFleetReconcile,
|
||||
type FleetReconcileCommandResult,
|
||||
type FleetReconcileDeps,
|
||||
type FleetReconcileResult,
|
||||
} from './fleet-reconciler.js';
|
||||
import {
|
||||
parseRosterV2,
|
||||
renderRosterV2Yaml,
|
||||
type FleetRosterV2,
|
||||
type FleetRosterV2Agent,
|
||||
} from './roster-v2.js';
|
||||
import { FleetTmuxRuntimeTransport } from './tmux-runtime-transport.js';
|
||||
|
||||
const holderIdentity = '11111111-1111-4111-8111-111111111111';
|
||||
const stoppedAgent: FleetRosterV2Agent = {
|
||||
name: 'coder0',
|
||||
alias: 'Coder 0',
|
||||
className: 'code',
|
||||
runtime: 'pi',
|
||||
provider: 'openai',
|
||||
model: 'gpt-5.6-sol',
|
||||
reasoning: 'high',
|
||||
toolPolicy: 'code',
|
||||
workingDirectory: '/srv/mosaic',
|
||||
persistentPersona: false,
|
||||
resetBetweenTasks: true,
|
||||
lifecycle: { enabled: true, desiredState: 'stopped' },
|
||||
launch: { yolo: true },
|
||||
};
|
||||
|
||||
const baseRoster: FleetRosterV2 = {
|
||||
version: 2,
|
||||
generation: 7,
|
||||
transport: 'tmux',
|
||||
tmux: { socketName: 'mosaic-fleet', holderSession: '_holder' },
|
||||
defaults: { workingDirectory: '/srv/mosaic', runtime: 'pi' },
|
||||
runtimes: { pi: { resetCommand: '/new' } },
|
||||
agents: [stoppedAgent],
|
||||
};
|
||||
|
||||
interface InjectedLifecycleFailure {
|
||||
readonly action: 'start' | 'stop' | 'restart';
|
||||
readonly service: string;
|
||||
readonly diagnostic: string;
|
||||
}
|
||||
|
||||
class FakeLifecycleHost {
|
||||
readonly calls: string[][] = [];
|
||||
readonly sessions = new Set<string>();
|
||||
readonly activeServices = new Set<string>();
|
||||
private failure: InjectedLifecycleFailure | undefined;
|
||||
|
||||
constructor(readonly roster: FleetRosterV2) {
|
||||
this.sessions.add(roster.tmux.holderSession);
|
||||
}
|
||||
|
||||
injectFailure(failure: InjectedLifecycleFailure): void {
|
||||
this.failure = failure;
|
||||
}
|
||||
|
||||
readonly run = async (
|
||||
command: string,
|
||||
args: readonly string[],
|
||||
): Promise<FleetReconcileCommandResult> => {
|
||||
this.calls.push([command, ...args]);
|
||||
if (command === 'tmux') return this.runTmux(args);
|
||||
if (command === 'systemctl') return this.runSystemctl(args);
|
||||
return { stdout: '', stderr: 'unsupported fake command', exitCode: 127 };
|
||||
};
|
||||
|
||||
private runTmux(args: readonly string[]): FleetReconcileCommandResult {
|
||||
if (args.includes('list-sessions')) {
|
||||
return { stdout: `${[...this.sessions].join('\n')}\n`, stderr: '', exitCode: 0 };
|
||||
}
|
||||
if (args.includes('has-session')) {
|
||||
const targetArgument = args[args.indexOf('-t') + 1];
|
||||
const sessionName = targetArgument?.replace(/^=/, '').split(':')[0];
|
||||
return {
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
exitCode: sessionName !== undefined && this.sessions.has(sessionName) ? 0 : 1,
|
||||
};
|
||||
}
|
||||
if (args.includes('show-environment')) {
|
||||
return {
|
||||
stdout: [
|
||||
'HOME=/home/mosaic',
|
||||
`MOSAIC_FLEET_OWNER=${holderIdentity}`,
|
||||
`MOSAIC_TMUX_HOLDER=${this.roster.tmux.holderSession}`,
|
||||
`MOSAIC_TMUX_SOCKET=${this.roster.tmux.socketName}`,
|
||||
'PATH=/usr/bin:/bin',
|
||||
'PWD=/home/mosaic',
|
||||
'',
|
||||
].join('\n'),
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
};
|
||||
}
|
||||
return { stdout: '', stderr: 'destructive tmux action rejected by fake', exitCode: 125 };
|
||||
}
|
||||
|
||||
private runSystemctl(args: readonly string[]): FleetReconcileCommandResult {
|
||||
const action = args[1];
|
||||
const service = args[2];
|
||||
if (action === 'show' && service !== undefined) {
|
||||
return {
|
||||
stdout: `ActiveState=${this.activeServices.has(service) ? 'active' : 'inactive'}\n`,
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
};
|
||||
}
|
||||
if (
|
||||
(action === 'start' || action === 'stop' || action === 'restart') &&
|
||||
service !== undefined
|
||||
) {
|
||||
this.applyLifecycleEffect(action, service);
|
||||
if (this.failure?.action === action && this.failure.service === service) {
|
||||
const diagnostic = this.failure.diagnostic;
|
||||
this.failure = undefined;
|
||||
return { stdout: '', stderr: diagnostic, exitCode: 1 };
|
||||
}
|
||||
return { stdout: '', stderr: '', exitCode: 0 };
|
||||
}
|
||||
return { stdout: '', stderr: 'unsupported fake systemctl action', exitCode: 125 };
|
||||
}
|
||||
|
||||
private applyLifecycleEffect(action: 'start' | 'stop' | 'restart', service: string): void {
|
||||
if (service === 'mosaic-tmux-holder.service') return;
|
||||
const match = /^mosaic-agent@(.+)\.service$/.exec(service);
|
||||
if (!match) return;
|
||||
const agentName = match[1];
|
||||
if (agentName === undefined) return;
|
||||
if (action === 'stop') {
|
||||
this.activeServices.delete(service);
|
||||
this.sessions.delete(agentName);
|
||||
return;
|
||||
}
|
||||
this.activeServices.add(service);
|
||||
this.sessions.add(agentName);
|
||||
}
|
||||
}
|
||||
|
||||
const cleanupDirectories: string[] = [];
|
||||
|
||||
afterEach(async (): Promise<void> => {
|
||||
vi.restoreAllMocks();
|
||||
process.exitCode = undefined;
|
||||
await Promise.all(
|
||||
cleanupDirectories.splice(0).map(async (directory: string): Promise<void> => {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
function reconcileDeps(host: FakeLifecycleHost): FleetReconcileDeps {
|
||||
return {
|
||||
runner: host.run,
|
||||
homeDirectory: '/home/mosaic',
|
||||
readHolderIdentity: async () => holderIdentity,
|
||||
validateRoster: async () => undefined,
|
||||
prepareProjections: async () => [{ agentName: 'coder0' }],
|
||||
applyProjection: async () => undefined,
|
||||
readRoster: async () => host.roster,
|
||||
acquireMutationLock: async () => async () => undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async function execute(
|
||||
host: FakeLifecycleHost,
|
||||
command: 'apply' | 'reconcile' | 'restart' | 'status' | 'stop',
|
||||
agentName?: string,
|
||||
): Promise<FleetReconcileResult> {
|
||||
return executeFleetReconcile({
|
||||
roster: host.roster,
|
||||
command,
|
||||
...(agentName === undefined ? {} : { agentName }),
|
||||
...(command === 'status' ? {} : { expectedGeneration: host.roster.generation }),
|
||||
deps: reconcileDeps(host),
|
||||
});
|
||||
}
|
||||
|
||||
async function fixtureHome(roster: FleetRosterV2): Promise<string> {
|
||||
const home = await mkdtemp(join(tmpdir(), 'mosaic-reconciler-acceptance-'));
|
||||
cleanupDirectories.push(home);
|
||||
const fleetDirectory = join(home, 'fleet');
|
||||
await mkdir(fleetDirectory, { mode: 0o700 });
|
||||
await chmod(home, 0o700);
|
||||
await chmod(fleetDirectory, 0o700);
|
||||
await writeFile(join(fleetDirectory, 'roster.yaml'), renderRosterV2Yaml(roster), { mode: 0o600 });
|
||||
return home;
|
||||
}
|
||||
|
||||
async function fixtureLegacyRoster(): Promise<string> {
|
||||
const home = await mkdtemp(join(tmpdir(), 'mosaic-reconciler-acceptance-v1-'));
|
||||
cleanupDirectories.push(home);
|
||||
const fleetDirectory = join(home, 'fleet');
|
||||
await mkdir(fleetDirectory, { mode: 0o700 });
|
||||
await chmod(home, 0o700);
|
||||
await chmod(fleetDirectory, 0o700);
|
||||
const rosterPath = join(fleetDirectory, 'roster.yaml');
|
||||
await writeFile(
|
||||
rosterPath,
|
||||
[
|
||||
'version: 1',
|
||||
'transport: tmux',
|
||||
'tmux:',
|
||||
' holder_session: _holder',
|
||||
'agents:',
|
||||
' - name: coder0',
|
||||
' runtime: pi',
|
||||
' class: code',
|
||||
'',
|
||||
].join('\n'),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
return rosterPath;
|
||||
}
|
||||
|
||||
function cliProgram(home: string, host: FakeLifecycleHost): Command {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerFleetCommand(program, {
|
||||
mosaicHome: home,
|
||||
runner: async (command: string, args: string[]): Promise<CommandResult> =>
|
||||
host.run(command, args),
|
||||
reconcileDeps: reconcileDeps(host),
|
||||
});
|
||||
return program;
|
||||
}
|
||||
|
||||
function captureJson(): string[] {
|
||||
const output: string[] = [];
|
||||
vi.spyOn(console, 'log').mockImplementation((line: string): void => {
|
||||
output.push(line);
|
||||
});
|
||||
return output;
|
||||
}
|
||||
|
||||
describe('FCM-M3-002 reconciler lifecycle acceptance', (): void => {
|
||||
it('observes named-socket drift through canonical roster-v2 parsing without runtime mutation', async (): Promise<void> => {
|
||||
const roster: FleetRosterV2 = {
|
||||
...baseRoster,
|
||||
agents: [
|
||||
stoppedAgent,
|
||||
{
|
||||
...stoppedAgent,
|
||||
name: 'reviewer0',
|
||||
alias: 'Reviewer 0',
|
||||
lifecycle: { enabled: true, desiredState: 'running' },
|
||||
},
|
||||
{
|
||||
...stoppedAgent,
|
||||
name: 'validator0',
|
||||
alias: 'Validator 0',
|
||||
lifecycle: { enabled: false, desiredState: 'stopped' },
|
||||
},
|
||||
],
|
||||
};
|
||||
const home = await fixtureHome(roster);
|
||||
const host = new FakeLifecycleHost(roster);
|
||||
host.sessions.add('coder0');
|
||||
host.sessions.add('validator0');
|
||||
host.sessions.add('coder0-shadow');
|
||||
const output = captureJson();
|
||||
|
||||
await cliProgram(home, host).parseAsync(['node', 'mosaic', 'fleet', 'status']);
|
||||
|
||||
expect(output).toHaveLength(1);
|
||||
expect(JSON.parse(output[0] ?? '{}')).toMatchObject({
|
||||
plan: {
|
||||
agents: [
|
||||
{ name: 'coder0', drift: ['unexpected-session'] },
|
||||
{ name: 'reviewer0', drift: ['missing-session'] },
|
||||
{ name: 'validator0', drift: ['unexpected-session', 'disabled-running'] },
|
||||
],
|
||||
unmanagedSessions: ['coder0-shadow'],
|
||||
},
|
||||
});
|
||||
expect(host.calls[0]).toEqual([
|
||||
'tmux',
|
||||
'-L',
|
||||
'mosaic-fleet',
|
||||
'list-sessions',
|
||||
'-F',
|
||||
'#{session_name}',
|
||||
]);
|
||||
expect(
|
||||
host.calls.every(
|
||||
(call: string[]): boolean =>
|
||||
call[0] !== 'tmux' || (call[1] === '-L' && call[2] === 'mosaic-fleet'),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
host.calls.every((call: string[]): boolean => call[0] !== 'systemctl' || call[2] === 'show'),
|
||||
).toBe(true);
|
||||
expect(process.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
label: 'missing',
|
||||
source: renderRosterV2Yaml(baseRoster).replace(/^ socket_name:.*\n/m, ''),
|
||||
},
|
||||
{
|
||||
label: 'empty',
|
||||
source: renderRosterV2Yaml(baseRoster).replace(/^ socket_name:.*$/m, ' socket_name: ""'),
|
||||
},
|
||||
])(
|
||||
'rejects a $label canonical roster-v2 tmux socket through parseRosterV2',
|
||||
({ source }): void => {
|
||||
expect(() => parseRosterV2(source, 'yaml')).toThrow(
|
||||
'Roster v2 tmux socket_name is required and must be a non-empty string.',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('uses the literal default tmux server only through the legacy-v1 loader and runtime transport boundary', async (): Promise<void> => {
|
||||
const rosterPath = await fixtureLegacyRoster();
|
||||
const runner = vi.fn<CommandRunner>(
|
||||
async (): Promise<CommandResult> => ({
|
||||
stdout: '111 pi 0 0 0 0\n',
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
}),
|
||||
);
|
||||
const transport = new FleetTmuxRuntimeTransport({
|
||||
mosaicHome: '/unused',
|
||||
rosterPath,
|
||||
runner,
|
||||
});
|
||||
|
||||
await expect(transport.verifySession('coder0')).resolves.toEqual({
|
||||
id: 'coder0',
|
||||
runtimeId: 'pi',
|
||||
socketName: '',
|
||||
});
|
||||
expect(runner).toHaveBeenCalledTimes(1);
|
||||
expect(runner).toHaveBeenCalledWith('tmux', [
|
||||
'list-panes',
|
||||
'-t',
|
||||
'=coder0:0.0',
|
||||
'-F',
|
||||
'#{pane_pid} #{pane_current_command} #{pane_dead} #{pane_activity} #{window_activity} #{session_activity}',
|
||||
]);
|
||||
expect(runner.mock.calls[0]?.[1]).not.toContain('-L');
|
||||
});
|
||||
|
||||
it('classifies unmanaged near-collisions and stops only the exact roster-owned service', async (): Promise<void> => {
|
||||
const host = new FakeLifecycleHost(baseRoster);
|
||||
host.sessions.add('coder0');
|
||||
host.sessions.add('coder0-shadow');
|
||||
host.sessions.add('unmanaged');
|
||||
host.activeServices.add('mosaic-agent@coder0.service');
|
||||
host.activeServices.add('mosaic-agent@coder0-shadow.service');
|
||||
|
||||
const observed = await execute(host, 'status');
|
||||
const stopped = await execute(host, 'stop', 'coder0');
|
||||
|
||||
expect(observed.plan.unmanagedSessions).toEqual(['coder0-shadow', 'unmanaged']);
|
||||
expect(stopped).toMatchObject({ applied: true, lifecycle: 'complete' });
|
||||
expect(host.activeServices.has('mosaic-agent@coder0.service')).toBe(false);
|
||||
expect(host.activeServices.has('mosaic-agent@coder0-shadow.service')).toBe(true);
|
||||
expect(host.sessions.has('coder0-shadow')).toBe(true);
|
||||
expect(host.sessions.has('unmanaged')).toBe(true);
|
||||
expect(host.calls).toContainEqual([
|
||||
'systemctl',
|
||||
'--user',
|
||||
'stop',
|
||||
'mosaic-agent@coder0.service',
|
||||
]);
|
||||
expect(
|
||||
host.calls.some(
|
||||
(call: string[]): boolean =>
|
||||
call.includes('kill-session') ||
|
||||
call.includes('coder0-shadow.service') ||
|
||||
call.includes('unmanaged.service'),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('preserves persisted stopped state through apply, reconcile, restart failure, and recovery reconcile', async (): Promise<void> => {
|
||||
const host = new FakeLifecycleHost(baseRoster);
|
||||
host.sessions.add('coder0');
|
||||
host.activeServices.add('mosaic-agent@coder0.service');
|
||||
|
||||
const applied = await execute(host, 'apply');
|
||||
const reconciled = await execute(host, 'reconcile');
|
||||
host.injectFailure({
|
||||
action: 'restart',
|
||||
service: 'mosaic-agent@coder0.service',
|
||||
diagnostic: 'crash after effect: TOKEN=acceptance-secret',
|
||||
});
|
||||
const partialRestart = await execute(host, 'restart', 'coder0');
|
||||
|
||||
expect(applied).toMatchObject({ applied: true, lifecycle: 'complete' });
|
||||
expect(reconciled).toMatchObject({ applied: true, lifecycle: 'complete' });
|
||||
expect(host.roster.agents[0]?.lifecycle.desiredState).toBe('stopped');
|
||||
expect(partialRestart).toMatchObject({
|
||||
applied: false,
|
||||
authoritativeRoster: 'unchanged',
|
||||
projections: 'not-applied',
|
||||
lifecycle: 'incomplete',
|
||||
recovery: {
|
||||
code: 'lifecycle-apply-failed',
|
||||
action: 'rerun-after-inspecting-owned-resources',
|
||||
},
|
||||
});
|
||||
expect(host.activeServices.has('mosaic-agent@coder0.service')).toBe(true);
|
||||
|
||||
const recovered = await execute(host, 'reconcile');
|
||||
|
||||
expect(recovered).toMatchObject({ applied: true, lifecycle: 'complete' });
|
||||
expect(host.roster.agents[0]?.lifecycle.desiredState).toBe('stopped');
|
||||
expect(host.activeServices.has('mosaic-agent@coder0.service')).toBe(false);
|
||||
expect(host.sessions.has('coder0')).toBe(false);
|
||||
const destructiveCalls = host.calls.filter(
|
||||
(call: string[]): boolean =>
|
||||
call[0] === 'systemctl' && ['start', 'stop', 'restart'].includes(call[2] ?? ''),
|
||||
);
|
||||
expect(
|
||||
destructiveCalls.every(
|
||||
(call: string[]): boolean => call[3] === 'mosaic-agent@coder0.service',
|
||||
),
|
||||
).toBe(true);
|
||||
expect(destructiveCalls.some((call: string[]): boolean => call[2] === 'start')).toBe(false);
|
||||
});
|
||||
|
||||
it('emits stable non-zero redacted JSON for a partial lifecycle effect', async (): Promise<void> => {
|
||||
const home = await fixtureHome(baseRoster);
|
||||
const host = new FakeLifecycleHost(baseRoster);
|
||||
host.injectFailure({
|
||||
action: 'restart',
|
||||
service: 'mosaic-agent@coder0.service',
|
||||
diagnostic: 'simulated runner stderr with PASSWORD=acceptance-secret',
|
||||
});
|
||||
const output = captureJson();
|
||||
|
||||
await cliProgram(home, host).parseAsync([
|
||||
'node',
|
||||
'mosaic',
|
||||
'fleet',
|
||||
'restart',
|
||||
'coder0',
|
||||
'--expected-generation',
|
||||
'7',
|
||||
]);
|
||||
|
||||
const line = output.at(-1) ?? '';
|
||||
expect(output).toHaveLength(1);
|
||||
expect(JSON.parse(line)).toEqual({
|
||||
applied: false,
|
||||
authoritativeRoster: 'unchanged',
|
||||
projections: 'not-applied',
|
||||
lifecycle: 'incomplete',
|
||||
plan: {
|
||||
generation: 7,
|
||||
holder: 'owned',
|
||||
agents: [
|
||||
{
|
||||
name: 'coder0',
|
||||
desiredState: 'stopped',
|
||||
enabled: true,
|
||||
systemd: 'inactive',
|
||||
tmux: 'missing',
|
||||
drift: [],
|
||||
},
|
||||
],
|
||||
unmanagedSessions: [],
|
||||
},
|
||||
recovery: {
|
||||
code: 'lifecycle-apply-failed',
|
||||
action: 'rerun-after-inspecting-owned-resources',
|
||||
},
|
||||
});
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(line).not.toContain('PASSWORD');
|
||||
expect(line).not.toContain('acceptance-secret');
|
||||
expect(line).not.toContain('simulated runner stderr');
|
||||
});
|
||||
});
|
||||
@@ -1,440 +0,0 @@
|
||||
import { chmod, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
acquirePrivateReconcileLock,
|
||||
FleetReconcileError,
|
||||
executeFleetReconcile,
|
||||
type FleetReconcileCommand,
|
||||
type FleetReconcileDeps,
|
||||
} from './fleet-reconciler.js';
|
||||
import type { FleetRosterV2 } from './roster-v2.js';
|
||||
|
||||
let cleanup: string | undefined;
|
||||
|
||||
afterEach(async (): Promise<void> => {
|
||||
if (cleanup) await rm(cleanup, { recursive: true, force: true });
|
||||
cleanup = undefined;
|
||||
});
|
||||
|
||||
async function lockHome(): Promise<string> {
|
||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-reconcile-lock-'));
|
||||
const fleet = join(cleanup, 'fleet');
|
||||
await mkdir(fleet, { mode: 0o700 });
|
||||
await chmod(cleanup, 0o700);
|
||||
await chmod(fleet, 0o700);
|
||||
return cleanup;
|
||||
}
|
||||
|
||||
const roster: FleetRosterV2 = {
|
||||
version: 2,
|
||||
generation: 7,
|
||||
transport: 'tmux',
|
||||
tmux: { socketName: 'mosaic-fleet', holderSession: '_holder' },
|
||||
defaults: { workingDirectory: '/srv/mosaic', runtime: 'pi' },
|
||||
runtimes: { pi: { resetCommand: '/new' } },
|
||||
agents: [
|
||||
{
|
||||
name: 'coder0',
|
||||
alias: 'Coder 0',
|
||||
className: 'code',
|
||||
runtime: 'pi',
|
||||
provider: 'openai',
|
||||
model: 'gpt-5.6-sol',
|
||||
reasoning: 'high',
|
||||
toolPolicy: 'code',
|
||||
workingDirectory: '/srv/mosaic',
|
||||
persistentPersona: false,
|
||||
resetBetweenTasks: true,
|
||||
lifecycle: { enabled: true, desiredState: 'stopped' },
|
||||
launch: { yolo: true },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function deps(overrides: Partial<FleetReconcileDeps> = {}): FleetReconcileDeps {
|
||||
return {
|
||||
runner: async (command, args) => {
|
||||
if (command === 'tmux' && args.includes('list-sessions')) {
|
||||
return { stdout: '_holder\ncoder0\n', stderr: '', exitCode: 0 };
|
||||
}
|
||||
if (command === 'tmux' && args.includes('show-environment')) {
|
||||
return {
|
||||
stdout:
|
||||
'HOME=/home/mosaic\nMOSAIC_FLEET_OWNER=11111111-1111-4111-8111-111111111111\nMOSAIC_TMUX_HOLDER=_holder\nMOSAIC_TMUX_SOCKET=mosaic-fleet\nPATH=/usr/bin:/bin\nPWD=/home/mosaic\n',
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
};
|
||||
}
|
||||
return { stdout: '', stderr: '', exitCode: 0 };
|
||||
},
|
||||
homeDirectory: '/home/mosaic',
|
||||
readHolderIdentity: async () => '11111111-1111-4111-8111-111111111111',
|
||||
validateRoster: async () => undefined,
|
||||
prepareProjections: async () => [{ agentName: 'coder0' }],
|
||||
applyProjection: async () => undefined,
|
||||
readRoster: async () => roster,
|
||||
acquireMutationLock: async () => async () => undefined,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function run(command: FleetReconcileCommand, overrides: Partial<FleetReconcileDeps> = {}) {
|
||||
return executeFleetReconcile({
|
||||
roster,
|
||||
command,
|
||||
...(command === 'status' || command === 'verify' || command === 'doctor'
|
||||
? {}
|
||||
: { expectedGeneration: 7 }),
|
||||
deps: deps({ readRoster: async () => roster, ...overrides }),
|
||||
});
|
||||
}
|
||||
|
||||
describe('fleet roster-owned reconciler', (): void => {
|
||||
it('fails closed on a symlinked fleet ancestor without touching its target', async (): Promise<void> => {
|
||||
const home = await lockHome();
|
||||
const fleet = join(home, 'fleet');
|
||||
const attacker = await mkdtemp(join(tmpdir(), 'mosaic-reconcile-attacker-'));
|
||||
await writeFile(join(attacker, 'sentinel'), 'unchanged\n', { mode: 0o600 });
|
||||
try {
|
||||
await rm(fleet, { recursive: true });
|
||||
await symlink(attacker, fleet, 'dir');
|
||||
await expect(acquirePrivateReconcileLock(home)()).rejects.toMatchObject({
|
||||
code: 'unsafe-managed-path',
|
||||
});
|
||||
expect(await readFile(join(attacker, 'sentinel'), 'utf8')).toBe('unchanged\n');
|
||||
} finally {
|
||||
await rm(attacker, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects unsafe ancestors, symlink leaves, EEXIST, and non-EEXIST lock creation failures', async (): Promise<void> => {
|
||||
const home = await lockHome();
|
||||
const fleet = join(home, 'fleet');
|
||||
const lockPath = join(fleet, 'roster.yaml.reconcile.lock');
|
||||
|
||||
await chmod(fleet, 0o770);
|
||||
await expect(acquirePrivateReconcileLock(home)()).rejects.toMatchObject({
|
||||
code: 'unsafe-managed-path',
|
||||
});
|
||||
await chmod(fleet, 0o700);
|
||||
|
||||
const target = join(home, 'target');
|
||||
await writeFile(target, 'target\n', { mode: 0o600 });
|
||||
await symlink(target, lockPath);
|
||||
await expect(acquirePrivateReconcileLock(home)()).rejects.toMatchObject({
|
||||
code: 'unsafe-lock',
|
||||
});
|
||||
await rm(lockPath);
|
||||
|
||||
await writeFile(lockPath, 'other\n', { mode: 0o600 });
|
||||
await expect(acquirePrivateReconcileLock(home)()).rejects.toMatchObject({
|
||||
code: 'concurrent-mutation',
|
||||
});
|
||||
await rm(lockPath);
|
||||
|
||||
const ioFailure = Object.assign(new Error('injected I/O failure'), { code: 'EIO' });
|
||||
await expect(
|
||||
acquirePrivateReconcileLock(home, async () => Promise.reject(ioFailure))(),
|
||||
).rejects.toMatchObject({ code: 'lock-io-failed' });
|
||||
});
|
||||
|
||||
it('does not unlink a replacement lock and normally releases its own lock', async (): Promise<void> => {
|
||||
const home = await lockHome();
|
||||
const lockPath = join(home, 'fleet', 'roster.yaml.reconcile.lock');
|
||||
const release = await acquirePrivateReconcileLock(home)();
|
||||
await rm(lockPath);
|
||||
await writeFile(lockPath, 'replacement\n', { mode: 0o600 });
|
||||
await expect(release()).rejects.toMatchObject({ code: 'lock-cleanup-failed' });
|
||||
expect(await readFile(lockPath, 'utf8')).toBe('replacement\n');
|
||||
|
||||
await rm(lockPath);
|
||||
const normalRelease = await acquirePrivateReconcileLock(home)();
|
||||
await normalRelease();
|
||||
await expect(readFile(lockPath, 'utf8')).rejects.toThrow();
|
||||
});
|
||||
it('requires a generation before any mutating preflight or effect', async (): Promise<void> => {
|
||||
let effects = 0;
|
||||
await expect(
|
||||
executeFleetReconcile({
|
||||
roster,
|
||||
command: 'apply',
|
||||
deps: deps({
|
||||
validateRoster: async () => {
|
||||
effects += 1;
|
||||
},
|
||||
}),
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 'missing-generation' });
|
||||
expect(effects).toBe(0);
|
||||
});
|
||||
|
||||
it('fences mutation against the canonical roster reread under lock', async (): Promise<void> => {
|
||||
let effects = 0;
|
||||
await expect(
|
||||
executeFleetReconcile({
|
||||
roster,
|
||||
command: 'apply',
|
||||
expectedGeneration: 7,
|
||||
deps: deps({
|
||||
readRoster: async () => ({ ...roster, generation: 8 }),
|
||||
runner: async () => {
|
||||
effects += 1;
|
||||
return { stdout: '', stderr: '', exitCode: 0 };
|
||||
},
|
||||
applyProjection: async () => {
|
||||
effects += 1;
|
||||
},
|
||||
}),
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 'stale-generation' });
|
||||
expect(effects).toBe(0);
|
||||
});
|
||||
|
||||
it('rejects stale generation before projection or lifecycle effects', async (): Promise<void> => {
|
||||
let effects = 0;
|
||||
await expect(
|
||||
executeFleetReconcile({
|
||||
roster,
|
||||
command: 'apply',
|
||||
expectedGeneration: 6,
|
||||
deps: deps({
|
||||
validateRoster: async () => {
|
||||
effects += 1;
|
||||
},
|
||||
}),
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 'stale-generation' });
|
||||
expect(effects).toBe(0);
|
||||
});
|
||||
|
||||
it('denies a concurrent mutation lock without effects and leaves observations lock-free', async (): Promise<void> => {
|
||||
let effects = 0;
|
||||
const busy = async (): Promise<() => Promise<void>> => {
|
||||
throw new FleetReconcileError('concurrent-mutation', 'busy');
|
||||
};
|
||||
await expect(
|
||||
run('apply', {
|
||||
acquireMutationLock: busy,
|
||||
applyProjection: async () => {
|
||||
effects += 1;
|
||||
},
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 'concurrent-mutation' });
|
||||
expect(effects).toBe(0);
|
||||
|
||||
await expect(run('doctor', { acquireMutationLock: busy })).resolves.toMatchObject({
|
||||
applied: false,
|
||||
lifecycle: 'not-applied',
|
||||
});
|
||||
});
|
||||
|
||||
it('always releases the mutation lock after success and partial failure', async (): Promise<void> => {
|
||||
let releases = 0;
|
||||
const lock = async (): Promise<() => Promise<void>> => async (): Promise<void> => {
|
||||
releases += 1;
|
||||
};
|
||||
await run('apply', { acquireMutationLock: lock });
|
||||
await run('apply', {
|
||||
acquireMutationLock: lock,
|
||||
applyProjection: async () => {
|
||||
throw new Error('injected failure');
|
||||
},
|
||||
});
|
||||
expect(releases).toBe(2);
|
||||
});
|
||||
|
||||
it('preserves stopped desired state during apply', async (): Promise<void> => {
|
||||
const calls: string[][] = [];
|
||||
const result = await run('apply', {
|
||||
runner: async (command, args) => {
|
||||
calls.push([command, ...args]);
|
||||
if (command === 'tmux' && args.includes('list-sessions')) {
|
||||
return { stdout: '_holder\ncoder0\n', stderr: '', exitCode: 0 };
|
||||
}
|
||||
if (command === 'tmux' && args.includes('show-environment')) {
|
||||
return {
|
||||
stdout:
|
||||
'HOME=/home/mosaic\nMOSAIC_FLEET_OWNER=11111111-1111-4111-8111-111111111111\nMOSAIC_TMUX_HOLDER=_holder\nMOSAIC_TMUX_SOCKET=mosaic-fleet\nPATH=/usr/bin:/bin\nPWD=/home/mosaic\n',
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
};
|
||||
}
|
||||
return { stdout: '', stderr: '', exitCode: 0 };
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.lifecycle).toBe('complete');
|
||||
expect(calls).not.toContainEqual([
|
||||
'systemctl',
|
||||
'--user',
|
||||
'start',
|
||||
'mosaic-agent@coder0.service',
|
||||
]);
|
||||
});
|
||||
|
||||
it('starts only an explicitly running roster agent with exact systemd targets', async (): Promise<void> => {
|
||||
const calls: string[][] = [];
|
||||
const runningRoster: FleetRosterV2 = {
|
||||
...roster,
|
||||
agents: [{ ...roster.agents[0]!, lifecycle: { enabled: true, desiredState: 'running' } }],
|
||||
};
|
||||
const result = await executeFleetReconcile({
|
||||
roster: runningRoster,
|
||||
command: 'apply',
|
||||
expectedGeneration: 7,
|
||||
deps: deps({
|
||||
readRoster: async () => runningRoster,
|
||||
runner: async (command, args) => {
|
||||
calls.push([command, ...args]);
|
||||
if (command === 'tmux' && args.includes('list-sessions')) {
|
||||
return { stdout: '', stderr: '', exitCode: 1 };
|
||||
}
|
||||
return { stdout: '', stderr: '', exitCode: 0 };
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result.lifecycle).toBe('complete');
|
||||
expect(calls).toContainEqual(['systemctl', '--user', 'start', 'mosaic-tmux-holder.service']);
|
||||
expect(calls).toContainEqual(['systemctl', '--user', 'start', 'mosaic-agent@coder0.service']);
|
||||
});
|
||||
|
||||
it('rejects a fake holder with contaminated global state before projection application', async (): Promise<void> => {
|
||||
let projectionApplied = false;
|
||||
await expect(
|
||||
run('apply', {
|
||||
runner: async (command, args) => {
|
||||
if (command === 'tmux' && args.includes('list-sessions')) {
|
||||
return { stdout: '_holder\ncoder0\n', stderr: '', exitCode: 0 };
|
||||
}
|
||||
if (command === 'tmux' && args.includes('show-environment')) {
|
||||
return { stdout: 'MOSAIC_FLEET_OWNER=forged\n', stderr: '', exitCode: 0 };
|
||||
}
|
||||
return { stdout: '', stderr: '', exitCode: 0 };
|
||||
},
|
||||
applyProjection: async () => {
|
||||
projectionApplied = true;
|
||||
},
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 'ownership-mismatch' });
|
||||
expect(projectionApplied).toBe(false);
|
||||
});
|
||||
|
||||
it('reports but never adopts unmanaged sessions', async (): Promise<void> => {
|
||||
await expect(
|
||||
run('apply', {
|
||||
runner: async (command, args) => {
|
||||
if (command === 'tmux' && args.includes('list-sessions')) {
|
||||
return { stdout: '_holder\ncoder0\nother\n', stderr: '', exitCode: 0 };
|
||||
}
|
||||
if (command === 'tmux' && args.includes('show-environment')) {
|
||||
return {
|
||||
stdout:
|
||||
'HOME=/home/mosaic\nMOSAIC_FLEET_OWNER=11111111-1111-4111-8111-111111111111\nMOSAIC_TMUX_HOLDER=_holder\nMOSAIC_TMUX_SOCKET=mosaic-fleet\nPATH=/usr/bin:/bin\nPWD=/home/mosaic\n',
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
};
|
||||
}
|
||||
return { stdout: '', stderr: '', exitCode: 0 };
|
||||
},
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 'unmanaged-session' } satisfies Partial<FleetReconcileError>);
|
||||
});
|
||||
|
||||
it('rejects remote inventory before a local lifecycle command is constructed', async (): Promise<void> => {
|
||||
const calls: string[][] = [];
|
||||
const remoteRoster = {
|
||||
...roster,
|
||||
agents: [{ ...roster.agents[0]!, remote: { host: 'inventory-only' } }],
|
||||
} as unknown as FleetRosterV2;
|
||||
|
||||
await expect(
|
||||
executeFleetReconcile({
|
||||
roster: remoteRoster,
|
||||
command: 'apply',
|
||||
expectedGeneration: 7,
|
||||
deps: deps({
|
||||
readRoster: async () => remoteRoster,
|
||||
runner: async (command, args) => {
|
||||
calls.push([command, ...args]);
|
||||
return { stdout: '', stderr: '', exitCode: 0 };
|
||||
},
|
||||
}),
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 'lifecycle-precondition-failed' });
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
it('plans without applying projections or lifecycle effects', async (): Promise<void> => {
|
||||
let applied = false;
|
||||
const result = await run('plan', {
|
||||
applyProjection: async () => {
|
||||
applied = true;
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.applied).toBe(false);
|
||||
expect(result.lifecycle).toBe('not-applied');
|
||||
expect(applied).toBe(false);
|
||||
});
|
||||
|
||||
it('reports a truthful partial result if projection application fails', async (): Promise<void> => {
|
||||
const result = await run('apply', {
|
||||
applyProjection: async () => {
|
||||
throw new Error('injected failure');
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
applied: false,
|
||||
projections: 'incomplete',
|
||||
lifecycle: 'not-applied',
|
||||
recovery: { code: 'projection-apply-failed', action: 'regenerate-projections-from-roster' },
|
||||
});
|
||||
});
|
||||
|
||||
it('adds cleanup diagnostics without masking projection or lifecycle partial truth', async (): Promise<void> => {
|
||||
const failingRelease = async (): Promise<never> => {
|
||||
throw new FleetReconcileError('lock-cleanup-failed', 'injected');
|
||||
};
|
||||
const projectionPartial = await run('apply', {
|
||||
applyProjection: async () => {
|
||||
throw new Error('injected projection failure');
|
||||
},
|
||||
acquireMutationLock: async () => failingRelease,
|
||||
});
|
||||
expect(projectionPartial).toMatchObject({
|
||||
projections: 'incomplete',
|
||||
lifecycle: 'not-applied',
|
||||
recovery: { code: 'projection-apply-failed' },
|
||||
cleanup: { code: 'lock-cleanup-failed', action: 'inspect-lock-before-retry' },
|
||||
});
|
||||
|
||||
const lifecyclePartial = await run('apply', {
|
||||
runner: async () => ({ stdout: '', stderr: '', exitCode: 1 }),
|
||||
acquireMutationLock: async () => failingRelease,
|
||||
});
|
||||
expect(lifecyclePartial).toMatchObject({
|
||||
projections: 'complete',
|
||||
lifecycle: 'incomplete',
|
||||
recovery: { code: 'lifecycle-apply-failed' },
|
||||
cleanup: { code: 'lock-cleanup-failed', action: 'inspect-lock-before-retry' },
|
||||
});
|
||||
});
|
||||
|
||||
it('adds cleanup diagnostics after successful effects without changing effect completion', async (): Promise<void> => {
|
||||
const result = await run('apply', {
|
||||
acquireMutationLock: async () => async () => {
|
||||
throw new FleetReconcileError('lock-cleanup-failed', 'injected');
|
||||
},
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
applied: true,
|
||||
projections: 'complete',
|
||||
lifecycle: 'complete',
|
||||
cleanup: { code: 'lock-cleanup-failed', action: 'inspect-lock-before-retry' },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,785 +0,0 @@
|
||||
import { constants } from 'node:fs';
|
||||
import { lstat, open, readFile, unlink, type FileHandle } from 'node:fs/promises';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
applyPreparedAgentEnvironmentProjection,
|
||||
prepareAgentEnvironmentProjection,
|
||||
type PreparedAgentEnvironmentProjection,
|
||||
} from './generated-env-boundary.js';
|
||||
import {
|
||||
validateRosterV2Semantics,
|
||||
type FleetRosterV2,
|
||||
type FleetRosterV2Agent,
|
||||
} from './roster-v2.js';
|
||||
|
||||
export type FleetReconcileCommand =
|
||||
| 'plan'
|
||||
| 'apply'
|
||||
| 'reconcile'
|
||||
| 'start'
|
||||
| 'stop'
|
||||
| 'restart'
|
||||
| 'status'
|
||||
| 'verify'
|
||||
| 'doctor';
|
||||
|
||||
export interface FleetReconcileCommandResult {
|
||||
readonly stdout: string;
|
||||
readonly stderr: string;
|
||||
readonly exitCode: number;
|
||||
}
|
||||
|
||||
export type FleetReconcileRunner = (
|
||||
command: string,
|
||||
args: readonly string[],
|
||||
) => Promise<FleetReconcileCommandResult>;
|
||||
|
||||
export interface FleetReconcileDeps {
|
||||
readonly runner: FleetReconcileRunner;
|
||||
readonly mosaicHome?: string;
|
||||
readonly rolesDir?: string;
|
||||
readonly overrideDir?: string;
|
||||
readonly homeDirectory?: string;
|
||||
readonly readHolderIdentity?: () => Promise<string>;
|
||||
readonly validateRoster?: (roster: FleetRosterV2) => Promise<void>;
|
||||
readonly prepareProjections?: (roster: FleetRosterV2) => Promise<readonly unknown[]>;
|
||||
readonly applyProjection?: (prepared: unknown) => Promise<unknown>;
|
||||
/** Canonical roster reader; mutation authority is reread under the private lock. */
|
||||
readonly readRoster?: () => Promise<FleetRosterV2>;
|
||||
/** Test seam; production uses a private exclusive roster-adjacent lock. */
|
||||
readonly acquireMutationLock?: () => Promise<() => Promise<void>>;
|
||||
/** Internal recursion guard for the under-lock canonical roster read. */
|
||||
readonly lockAlreadyHeld?: boolean;
|
||||
}
|
||||
|
||||
export interface FleetReconcileRequest {
|
||||
readonly roster: FleetRosterV2;
|
||||
readonly command: FleetReconcileCommand;
|
||||
readonly agentName?: string;
|
||||
readonly expectedGeneration?: number;
|
||||
readonly deps: FleetReconcileDeps;
|
||||
}
|
||||
|
||||
export interface FleetReconcileObservedAgent {
|
||||
readonly name: string;
|
||||
readonly desiredState: 'running' | 'stopped';
|
||||
readonly enabled: boolean;
|
||||
readonly systemd: 'active' | 'inactive' | 'unknown';
|
||||
readonly tmux: 'present' | 'missing';
|
||||
readonly drift: readonly ('missing-session' | 'unexpected-session' | 'disabled-running')[];
|
||||
}
|
||||
|
||||
export interface FleetReconcilePlan {
|
||||
readonly generation: number;
|
||||
readonly holder: 'owned' | 'missing' | 'ownership-mismatch';
|
||||
readonly agents: readonly FleetReconcileObservedAgent[];
|
||||
readonly unmanagedSessions: readonly string[];
|
||||
}
|
||||
|
||||
export type FleetReconcileProjectionState = 'not-applied' | 'complete' | 'incomplete';
|
||||
export type FleetReconcileLifecycleState = 'not-applied' | 'complete' | 'incomplete';
|
||||
|
||||
export interface FleetReconcileResult {
|
||||
readonly applied: boolean;
|
||||
readonly authoritativeRoster: 'unchanged';
|
||||
readonly projections: FleetReconcileProjectionState;
|
||||
readonly lifecycle: FleetReconcileLifecycleState;
|
||||
readonly plan: FleetReconcilePlan;
|
||||
readonly recovery?: {
|
||||
readonly code: 'projection-apply-failed' | 'lifecycle-apply-failed';
|
||||
readonly action:
|
||||
| 'regenerate-projections-from-roster'
|
||||
| 'rerun-after-inspecting-owned-resources';
|
||||
};
|
||||
/** Additive: effects remain truthful when private lock cleanup cannot be proven. */
|
||||
readonly cleanup?: {
|
||||
readonly code: 'lock-cleanup-failed';
|
||||
readonly action: 'inspect-lock-before-retry';
|
||||
};
|
||||
}
|
||||
|
||||
export class FleetReconcileError extends Error {
|
||||
constructor(
|
||||
readonly code:
|
||||
| 'missing-generation'
|
||||
| 'stale-generation'
|
||||
| 'concurrent-mutation'
|
||||
| 'unsafe-managed-path'
|
||||
| 'unsafe-lock'
|
||||
| 'lock-io-failed'
|
||||
| 'lock-cleanup-failed'
|
||||
| 'agent-not-found'
|
||||
| 'disabled-agent'
|
||||
| 'ownership-mismatch'
|
||||
| 'unmanaged-session'
|
||||
| 'lifecycle-precondition-failed',
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = FleetReconcileError.name;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconciles only local roster-owned projections and exact service targets.
|
||||
* The roster is read-only desired state: no observed runtime result ever writes it.
|
||||
*/
|
||||
export async function executeFleetReconcile(
|
||||
request: FleetReconcileRequest,
|
||||
): Promise<FleetReconcileResult> {
|
||||
const mutating =
|
||||
request.command === 'apply' ||
|
||||
request.command === 'reconcile' ||
|
||||
isLifecycleCommand(request.command);
|
||||
if (mutating && !request.deps.lockAlreadyHeld) {
|
||||
if (request.expectedGeneration === undefined) assertExpectedGeneration(request);
|
||||
const acquire =
|
||||
request.deps.acquireMutationLock ?? acquirePrivateReconcileLock(mosaicHomeFor(request.deps));
|
||||
const release = await acquire();
|
||||
let result: FleetReconcileResult | undefined;
|
||||
let primaryError: unknown;
|
||||
try {
|
||||
if (!request.deps.readRoster) {
|
||||
throw new FleetReconcileError(
|
||||
'lifecycle-precondition-failed',
|
||||
'Canonical roster state cannot be read for mutation.',
|
||||
);
|
||||
}
|
||||
const canonicalRoster = await request.deps.readRoster();
|
||||
result = await executeFleetReconcile({
|
||||
...request,
|
||||
roster: canonicalRoster,
|
||||
deps: { ...request.deps, lockAlreadyHeld: true },
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
primaryError = error;
|
||||
}
|
||||
try {
|
||||
await release();
|
||||
} catch (cleanupError: unknown) {
|
||||
if (result) {
|
||||
return {
|
||||
...result,
|
||||
cleanup: { code: 'lock-cleanup-failed', action: 'inspect-lock-before-retry' },
|
||||
};
|
||||
}
|
||||
if (primaryError) throw primaryError;
|
||||
throw cleanupError;
|
||||
}
|
||||
if (primaryError) throw primaryError;
|
||||
return result as FleetReconcileResult;
|
||||
}
|
||||
|
||||
assertExpectedGeneration(request);
|
||||
assertLocalRosterOnly(request.roster);
|
||||
const validateRoster = request.deps.validateRoster ?? defaultValidateRoster(request);
|
||||
await validateRoster(request.roster);
|
||||
const plan = scopePlan(await observeFleet(request.roster, request.deps), request.agentName);
|
||||
|
||||
if (request.command === 'status' || request.command === 'doctor') {
|
||||
return {
|
||||
applied: false,
|
||||
authoritativeRoster: 'unchanged',
|
||||
projections: 'not-applied',
|
||||
lifecycle: 'not-applied',
|
||||
plan,
|
||||
};
|
||||
}
|
||||
if (request.command === 'verify') {
|
||||
assertVerificationSafe(plan);
|
||||
return {
|
||||
applied: false,
|
||||
authoritativeRoster: 'unchanged',
|
||||
projections: 'not-applied',
|
||||
lifecycle: 'not-applied',
|
||||
plan,
|
||||
};
|
||||
}
|
||||
|
||||
assertMutationSafe(plan, request.command);
|
||||
const prepareProjections = request.deps.prepareProjections ?? defaultPrepareProjections(request);
|
||||
const prepared = await prepareProjections(request.roster);
|
||||
if (request.command === 'plan') {
|
||||
return {
|
||||
applied: false,
|
||||
authoritativeRoster: 'unchanged',
|
||||
projections: 'not-applied',
|
||||
lifecycle: 'not-applied',
|
||||
plan,
|
||||
};
|
||||
}
|
||||
const targetAgents = targetAgentsFor(request.roster, request.agentName);
|
||||
const release = async (): Promise<void> => undefined;
|
||||
let result: FleetReconcileResult | undefined;
|
||||
let primaryError: unknown;
|
||||
try {
|
||||
if (request.command !== 'apply' && request.command !== 'reconcile') {
|
||||
result = await executeExplicitLifecycle(request, plan, targetAgents);
|
||||
} else {
|
||||
const applyProjection = request.deps.applyProjection ?? defaultApplyProjection;
|
||||
try {
|
||||
for (const projection of prepared) await applyProjection(projection);
|
||||
} catch {
|
||||
result = {
|
||||
applied: false,
|
||||
authoritativeRoster: 'unchanged',
|
||||
projections: 'incomplete',
|
||||
lifecycle: 'not-applied',
|
||||
plan,
|
||||
recovery: {
|
||||
code: 'projection-apply-failed',
|
||||
action: 'regenerate-projections-from-roster',
|
||||
},
|
||||
};
|
||||
}
|
||||
if (!result) {
|
||||
try {
|
||||
await applyDesiredLifecycle(request.roster, plan, request.deps);
|
||||
result = {
|
||||
applied: true,
|
||||
authoritativeRoster: 'unchanged',
|
||||
projections: 'complete',
|
||||
lifecycle: 'complete',
|
||||
plan,
|
||||
};
|
||||
} catch {
|
||||
result = {
|
||||
applied: false,
|
||||
authoritativeRoster: 'unchanged',
|
||||
projections: 'complete',
|
||||
lifecycle: 'incomplete',
|
||||
plan,
|
||||
recovery: {
|
||||
code: 'lifecycle-apply-failed',
|
||||
action: 'rerun-after-inspecting-owned-resources',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
primaryError = error;
|
||||
}
|
||||
|
||||
try {
|
||||
await release();
|
||||
} catch (cleanupError: unknown) {
|
||||
if (result) {
|
||||
return {
|
||||
...result,
|
||||
cleanup: { code: 'lock-cleanup-failed', action: 'inspect-lock-before-retry' },
|
||||
};
|
||||
}
|
||||
if (primaryError) throw primaryError;
|
||||
throw cleanupError;
|
||||
}
|
||||
if (primaryError) throw primaryError;
|
||||
return result as FleetReconcileResult;
|
||||
}
|
||||
|
||||
function assertLocalRosterOnly(roster: FleetRosterV2): void {
|
||||
for (const agent of roster.agents) {
|
||||
const untypedAgent = agent as unknown as Record<string, unknown>;
|
||||
if (
|
||||
Object.hasOwn(untypedAgent, 'remote') ||
|
||||
Object.hasOwn(untypedAgent, 'ssh') ||
|
||||
Object.hasOwn(untypedAgent, 'connector') ||
|
||||
Object.hasOwn(untypedAgent, 'host')
|
||||
) {
|
||||
throw new FleetReconcileError(
|
||||
'lifecycle-precondition-failed',
|
||||
'Remote or connector inventory cannot receive local lifecycle actions.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function assertExpectedGeneration(request: FleetReconcileRequest): void {
|
||||
if (isObservational(request.command)) return;
|
||||
if (request.expectedGeneration === undefined) {
|
||||
throw new FleetReconcileError(
|
||||
'missing-generation',
|
||||
'A roster generation is required for mutation.',
|
||||
);
|
||||
}
|
||||
if (request.expectedGeneration !== request.roster.generation) {
|
||||
throw new FleetReconcileError('stale-generation', 'The roster generation is stale.');
|
||||
}
|
||||
}
|
||||
|
||||
function isObservational(command: FleetReconcileCommand): boolean {
|
||||
return command === 'plan' || command === 'status' || command === 'verify' || command === 'doctor';
|
||||
}
|
||||
|
||||
async function observeFleet(
|
||||
roster: FleetRosterV2,
|
||||
deps: FleetReconcileDeps,
|
||||
): Promise<FleetReconcilePlan> {
|
||||
const sessionsResult = await run(deps, 'tmux', [
|
||||
...tmuxSocketArgs(roster.tmux.socketName),
|
||||
'list-sessions',
|
||||
'-F',
|
||||
'#{session_name}',
|
||||
]);
|
||||
if (sessionsResult.exitCode !== 0) {
|
||||
return {
|
||||
generation: roster.generation,
|
||||
holder: 'missing',
|
||||
agents: await observeAgents(roster, deps, new Set<string>()),
|
||||
unmanagedSessions: [],
|
||||
};
|
||||
}
|
||||
|
||||
const sessions = new Set(
|
||||
sessionsResult.stdout
|
||||
.split('\n')
|
||||
.map((value: string): string => value.trim())
|
||||
.filter((value: string): boolean => value.length > 0),
|
||||
);
|
||||
const knownSessions = new Set([
|
||||
roster.tmux.holderSession,
|
||||
...roster.agents.map((agent) => agent.name),
|
||||
]);
|
||||
const unmanagedSessions = [...sessions].filter(
|
||||
(session: string): boolean => !knownSessions.has(session),
|
||||
);
|
||||
const holder = await observeHolder(roster, deps, sessions);
|
||||
return {
|
||||
generation: roster.generation,
|
||||
holder,
|
||||
agents: await observeAgents(roster, deps, sessions),
|
||||
unmanagedSessions: Object.freeze(unmanagedSessions.sort()),
|
||||
};
|
||||
}
|
||||
|
||||
async function observeHolder(
|
||||
roster: FleetRosterV2,
|
||||
deps: FleetReconcileDeps,
|
||||
sessions: ReadonlySet<string>,
|
||||
): Promise<FleetReconcilePlan['holder']> {
|
||||
if (!sessions.has(roster.tmux.holderSession)) return 'ownership-mismatch';
|
||||
let owner: string;
|
||||
try {
|
||||
owner = await (deps.readHolderIdentity ?? defaultReadHolderIdentity(mosaicHomeFor(deps)))();
|
||||
} catch {
|
||||
return 'ownership-mismatch';
|
||||
}
|
||||
const environment = await run(deps, 'tmux', [
|
||||
...tmuxSocketArgs(roster.tmux.socketName),
|
||||
'show-environment',
|
||||
'-g',
|
||||
]);
|
||||
if (environment.exitCode !== 0) return 'ownership-mismatch';
|
||||
const homeDirectory = deps.homeDirectory ?? homedir();
|
||||
const expected = [
|
||||
`HOME=${homeDirectory}`,
|
||||
`MOSAIC_FLEET_OWNER=${owner}`,
|
||||
`MOSAIC_TMUX_HOLDER=${roster.tmux.holderSession}`,
|
||||
`MOSAIC_TMUX_SOCKET=${roster.tmux.socketName}`,
|
||||
'PATH=/usr/bin:/bin',
|
||||
`PWD=${homeDirectory}`,
|
||||
].sort();
|
||||
const actual = environment.stdout
|
||||
.split('\n')
|
||||
.filter((line: string): boolean => line.length > 0)
|
||||
.sort();
|
||||
return sameStringArray(actual, expected) ? 'owned' : 'ownership-mismatch';
|
||||
}
|
||||
|
||||
async function observeAgents(
|
||||
roster: FleetRosterV2,
|
||||
deps: FleetReconcileDeps,
|
||||
sessions: ReadonlySet<string>,
|
||||
): Promise<readonly FleetReconcileObservedAgent[]> {
|
||||
return Promise.all(
|
||||
roster.agents.map(async (agent: FleetRosterV2Agent): Promise<FleetReconcileObservedAgent> => {
|
||||
const service = await run(deps, 'systemctl', [
|
||||
'--user',
|
||||
'show',
|
||||
`mosaic-agent@${agent.name}.service`,
|
||||
'-p',
|
||||
'ActiveState',
|
||||
]);
|
||||
const active = /^ActiveState=active$/m.test(service.stdout)
|
||||
? 'active'
|
||||
: service.exitCode === 0
|
||||
? 'inactive'
|
||||
: 'unknown';
|
||||
const tmux = sessions.has(agent.name) ? 'present' : 'missing';
|
||||
const drift: Array<'missing-session' | 'unexpected-session' | 'disabled-running'> = [];
|
||||
if (
|
||||
agent.lifecycle.enabled &&
|
||||
agent.lifecycle.desiredState === 'running' &&
|
||||
tmux === 'missing'
|
||||
) {
|
||||
drift.push('missing-session');
|
||||
}
|
||||
if (agent.lifecycle.desiredState === 'stopped' && tmux === 'present')
|
||||
drift.push('unexpected-session');
|
||||
if (!agent.lifecycle.enabled && tmux === 'present') drift.push('disabled-running');
|
||||
return {
|
||||
name: agent.name,
|
||||
desiredState: agent.lifecycle.desiredState,
|
||||
enabled: agent.lifecycle.enabled,
|
||||
systemd: active,
|
||||
tmux,
|
||||
drift: Object.freeze(drift),
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function assertMutationSafe(plan: FleetReconcilePlan, command: FleetReconcileCommand): void {
|
||||
if (plan.holder === 'ownership-mismatch') {
|
||||
throw new FleetReconcileError(
|
||||
'ownership-mismatch',
|
||||
'The named tmux server ownership cannot be proven.',
|
||||
);
|
||||
}
|
||||
if (plan.unmanagedSessions.length > 0 && affectsHolder(command)) {
|
||||
throw new FleetReconcileError(
|
||||
'unmanaged-session',
|
||||
'Unmanaged sessions are present on the named socket.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function affectsHolder(command: FleetReconcileCommand): boolean {
|
||||
return (
|
||||
command === 'apply' || command === 'reconcile' || command === 'start' || command === 'restart'
|
||||
);
|
||||
}
|
||||
|
||||
function scopePlan(plan: FleetReconcilePlan, agentName?: string): FleetReconcilePlan {
|
||||
if (agentName === undefined) return plan;
|
||||
const agent = plan.agents.find(
|
||||
(candidate: FleetReconcileObservedAgent): boolean => candidate.name === agentName,
|
||||
);
|
||||
if (!agent)
|
||||
throw new FleetReconcileError('agent-not-found', 'The lifecycle target is not roster-owned.');
|
||||
return { ...plan, agents: [agent] };
|
||||
}
|
||||
|
||||
function assertVerificationSafe(plan: FleetReconcilePlan): void {
|
||||
if (plan.holder !== 'owned' || plan.unmanagedSessions.length > 0) {
|
||||
throw new FleetReconcileError(
|
||||
'lifecycle-precondition-failed',
|
||||
'Fleet ownership cannot be verified.',
|
||||
);
|
||||
}
|
||||
if (plan.agents.some((agent: FleetReconcileObservedAgent): boolean => agent.drift.length > 0)) {
|
||||
throw new FleetReconcileError(
|
||||
'lifecycle-precondition-failed',
|
||||
'Fleet drift prevents verification.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function targetAgentsFor(roster: FleetRosterV2, agentName?: string): readonly FleetRosterV2Agent[] {
|
||||
if (agentName === undefined) return roster.agents;
|
||||
const agent = roster.agents.find(
|
||||
(candidate: FleetRosterV2Agent): boolean => candidate.name === agentName,
|
||||
);
|
||||
if (!agent)
|
||||
throw new FleetReconcileError('agent-not-found', 'The lifecycle target is not roster-owned.');
|
||||
return [agent];
|
||||
}
|
||||
|
||||
async function executeExplicitLifecycle(
|
||||
request: FleetReconcileRequest,
|
||||
plan: FleetReconcilePlan,
|
||||
agents: readonly FleetRosterV2Agent[],
|
||||
): Promise<FleetReconcileResult> {
|
||||
if (request.command === 'start') {
|
||||
for (const agent of agents) {
|
||||
if (!agent.lifecycle.enabled) {
|
||||
throw new FleetReconcileError(
|
||||
'disabled-agent',
|
||||
'A disabled roster agent cannot be started.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
if (request.command === 'start' && plan.holder === 'missing') {
|
||||
await runChecked(request.deps, 'systemctl', [
|
||||
'--user',
|
||||
'start',
|
||||
'mosaic-tmux-holder.service',
|
||||
]);
|
||||
}
|
||||
for (const agent of agents) {
|
||||
await runChecked(request.deps, 'systemctl', [
|
||||
'--user',
|
||||
request.command,
|
||||
`mosaic-agent@${agent.name}.service`,
|
||||
]);
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
applied: false,
|
||||
authoritativeRoster: 'unchanged',
|
||||
projections: 'not-applied',
|
||||
lifecycle: 'incomplete',
|
||||
plan,
|
||||
recovery: {
|
||||
code: 'lifecycle-apply-failed',
|
||||
action: 'rerun-after-inspecting-owned-resources',
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
applied: true,
|
||||
authoritativeRoster: 'unchanged',
|
||||
projections: 'not-applied',
|
||||
lifecycle: 'complete',
|
||||
plan,
|
||||
};
|
||||
}
|
||||
|
||||
async function applyDesiredLifecycle(
|
||||
roster: FleetRosterV2,
|
||||
plan: FleetReconcilePlan,
|
||||
deps: FleetReconcileDeps,
|
||||
): Promise<void> {
|
||||
const needsRunningAgent = roster.agents.some(
|
||||
(agent: FleetRosterV2Agent): boolean =>
|
||||
agent.lifecycle.enabled && agent.lifecycle.desiredState === 'running',
|
||||
);
|
||||
if (needsRunningAgent && plan.holder === 'missing') {
|
||||
await runChecked(deps, 'systemctl', ['--user', 'start', 'mosaic-tmux-holder.service']);
|
||||
}
|
||||
for (const agent of roster.agents) {
|
||||
const action =
|
||||
agent.lifecycle.enabled && agent.lifecycle.desiredState === 'running' ? 'start' : 'stop';
|
||||
await runChecked(deps, 'systemctl', ['--user', action, `mosaic-agent@${agent.name}.service`]);
|
||||
}
|
||||
}
|
||||
|
||||
function defaultValidateRoster(
|
||||
request: FleetReconcileRequest,
|
||||
): (roster: FleetRosterV2) => Promise<void> {
|
||||
return async (roster: FleetRosterV2): Promise<void> => {
|
||||
const mosaicHome = mosaicHomeFor(request.deps);
|
||||
await validateRosterV2Semantics(roster, {
|
||||
rolesDir: request.deps.rolesDir ?? join(mosaicHome, 'fleet', 'roles'),
|
||||
overrideDir: request.deps.overrideDir ?? join(mosaicHome, 'fleet', 'roles.local'),
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function defaultPrepareProjections(
|
||||
request: FleetReconcileRequest,
|
||||
): (roster: FleetRosterV2) => Promise<readonly PreparedAgentEnvironmentProjection[]> {
|
||||
return async (roster: FleetRosterV2): Promise<readonly PreparedAgentEnvironmentProjection[]> => {
|
||||
const mosaicHome = mosaicHomeFor(request.deps);
|
||||
return Promise.all(
|
||||
roster.agents.map(
|
||||
(agent: FleetRosterV2Agent): Promise<PreparedAgentEnvironmentProjection> =>
|
||||
prepareAgentEnvironmentProjection({
|
||||
mosaicHome,
|
||||
agentEnvDir: join(mosaicHome, 'fleet', 'agents'),
|
||||
agentName: agent.name,
|
||||
generated: {
|
||||
MOSAIC_AGENT_NAME: agent.name,
|
||||
MOSAIC_AGENT_CLASS: agent.className,
|
||||
MOSAIC_AGENT_RUNTIME: agent.runtime,
|
||||
MOSAIC_AGENT_MODEL: agent.model,
|
||||
MOSAIC_AGENT_REASONING: agent.reasoning,
|
||||
MOSAIC_AGENT_TOOL_POLICY: agent.toolPolicy,
|
||||
MOSAIC_AGENT_WORKDIR: agent.workingDirectory,
|
||||
MOSAIC_TMUX_SOCKET: roster.tmux.socketName,
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
async function defaultApplyProjection(prepared: unknown): Promise<unknown> {
|
||||
return applyPreparedAgentEnvironmentProjection(prepared as PreparedAgentEnvironmentProjection);
|
||||
}
|
||||
|
||||
function mosaicHomeFor(deps: FleetReconcileDeps): string {
|
||||
return deps.mosaicHome ?? join(homedir(), '.config', 'mosaic');
|
||||
}
|
||||
|
||||
/** Acquires a private lock only after proving the canonical managed path. */
|
||||
export function acquirePrivateReconcileLock(
|
||||
mosaicHome: string,
|
||||
openLock: typeof open = open,
|
||||
): () => Promise<() => Promise<void>> {
|
||||
const fleetDir = join(mosaicHome, 'fleet');
|
||||
const lockPath = join(fleetDir, 'roster.yaml.reconcile.lock');
|
||||
return async (): Promise<() => Promise<void>> => {
|
||||
await assertPrivateManagedDirectory(mosaicHome);
|
||||
await assertPrivateManagedDirectory(fleetDir);
|
||||
await assertSafeLockLeafIfPresent(lockPath);
|
||||
|
||||
let handle: FileHandle;
|
||||
try {
|
||||
handle = await openLock(lockPath, 'wx', 0o600);
|
||||
} catch (error: unknown) {
|
||||
if (isCode(error, 'EEXIST')) {
|
||||
await assertSafeLockLeafIfPresent(lockPath);
|
||||
throw new FleetReconcileError(
|
||||
'concurrent-mutation',
|
||||
'Another roster reconciliation is in progress.',
|
||||
);
|
||||
}
|
||||
throw new FleetReconcileError('lock-io-failed', 'The reconciliation lock cannot be created.');
|
||||
}
|
||||
|
||||
const token = randomUUID();
|
||||
try {
|
||||
await handle.writeFile(`${token}\n`, 'utf8');
|
||||
const opened = await handle.stat();
|
||||
await handle.close();
|
||||
await assertLockOwnership(lockPath, opened.dev, opened.ino, token, 'unsafe-lock');
|
||||
return async (): Promise<void> => {
|
||||
try {
|
||||
await assertLockOwnership(lockPath, opened.dev, opened.ino, token, 'lock-cleanup-failed');
|
||||
await assertLockOwnership(lockPath, opened.dev, opened.ino, token, 'lock-cleanup-failed');
|
||||
await unlink(lockPath);
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof FleetReconcileError) throw error;
|
||||
throw new FleetReconcileError(
|
||||
'lock-cleanup-failed',
|
||||
'The reconciliation lock cleanup failed.',
|
||||
);
|
||||
}
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
await handle.close().catch((): void => {});
|
||||
if (error instanceof FleetReconcileError) throw error;
|
||||
throw new FleetReconcileError(
|
||||
'lock-io-failed',
|
||||
'The reconciliation lock cannot be initialized.',
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function assertPrivateManagedDirectory(path: string): Promise<void> {
|
||||
try {
|
||||
const metadata = await lstat(path);
|
||||
if (!metadata.isDirectory() || metadata.isSymbolicLink() || (metadata.mode & 0o077) !== 0) {
|
||||
throw new FleetReconcileError('unsafe-managed-path', 'The managed lock ancestor is unsafe.');
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof FleetReconcileError) throw error;
|
||||
throw new FleetReconcileError(
|
||||
'unsafe-managed-path',
|
||||
'The managed lock ancestor is unavailable.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function assertSafeLockLeafIfPresent(lockPath: string): Promise<void> {
|
||||
try {
|
||||
const metadata = await lstat(lockPath);
|
||||
if (!metadata.isFile() || metadata.isSymbolicLink() || (metadata.mode & 0o077) !== 0) {
|
||||
throw new FleetReconcileError('unsafe-lock', 'The reconciliation lock path is unsafe.');
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (isCode(error, 'ENOENT')) return;
|
||||
if (error instanceof FleetReconcileError) throw error;
|
||||
throw new FleetReconcileError('unsafe-lock', 'The reconciliation lock path is unavailable.');
|
||||
}
|
||||
}
|
||||
|
||||
async function assertLockOwnership(
|
||||
lockPath: string,
|
||||
device: number,
|
||||
inode: number,
|
||||
token: string,
|
||||
failureCode: 'unsafe-lock' | 'lock-cleanup-failed',
|
||||
): Promise<void> {
|
||||
try {
|
||||
const metadata = await lstat(lockPath);
|
||||
if (
|
||||
!metadata.isFile() ||
|
||||
metadata.isSymbolicLink() ||
|
||||
(metadata.mode & 0o077) !== 0 ||
|
||||
metadata.dev !== device ||
|
||||
metadata.ino !== inode
|
||||
) {
|
||||
throw new FleetReconcileError(failureCode, 'The reconciliation lock ownership changed.');
|
||||
}
|
||||
const handle = await open(lockPath, constants.O_RDONLY | constants.O_NOFOLLOW);
|
||||
try {
|
||||
const opened = await handle.stat();
|
||||
const contents = await handle.readFile({ encoding: 'utf8' });
|
||||
if (opened.dev !== device || opened.ino !== inode || contents !== `${token}\n`) {
|
||||
throw new FleetReconcileError(failureCode, 'The reconciliation lock ownership changed.');
|
||||
}
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof FleetReconcileError) throw error;
|
||||
throw new FleetReconcileError(
|
||||
failureCode,
|
||||
'The reconciliation lock ownership cannot be proven.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function isLifecycleCommand(command: FleetReconcileCommand): boolean {
|
||||
return command === 'start' || command === 'stop' || command === 'restart';
|
||||
}
|
||||
|
||||
function isCode(error: unknown, code: string): boolean {
|
||||
return typeof error === 'object' && error !== null && 'code' in error && error.code === code;
|
||||
}
|
||||
|
||||
function defaultReadHolderIdentity(mosaicHome: string): () => Promise<string> {
|
||||
return async (): Promise<string> => {
|
||||
const fleetDir = join(mosaicHome, 'fleet');
|
||||
const runDir = join(fleetDir, 'run');
|
||||
for (const directory of [mosaicHome, fleetDir, runDir]) {
|
||||
const metadata = await lstat(directory);
|
||||
if (!metadata.isDirectory() || metadata.isSymbolicLink() || (metadata.mode & 0o077) !== 0) {
|
||||
throw new Error('Unsafe holder identity ancestor.');
|
||||
}
|
||||
}
|
||||
const identityPath = join(runDir, 'holder-owner');
|
||||
const metadata = await lstat(identityPath);
|
||||
if (!metadata.isFile() || metadata.isSymbolicLink() || (metadata.mode & 0o077) !== 0) {
|
||||
throw new Error('Unsafe holder identity.');
|
||||
}
|
||||
const value = (await readFile(identityPath, 'utf8')).trim();
|
||||
if (!/^[a-f0-9-]{36}$/.test(value)) throw new Error('Malformed holder identity.');
|
||||
return value;
|
||||
};
|
||||
}
|
||||
|
||||
async function run(
|
||||
deps: FleetReconcileDeps,
|
||||
command: string,
|
||||
args: readonly string[],
|
||||
): Promise<FleetReconcileCommandResult> {
|
||||
return deps.runner(command, args);
|
||||
}
|
||||
|
||||
async function runChecked(
|
||||
deps: FleetReconcileDeps,
|
||||
command: string,
|
||||
args: readonly string[],
|
||||
): Promise<void> {
|
||||
const result = await run(deps, command, args);
|
||||
if (result.exitCode !== 0) throw new Error('Lifecycle action failed.');
|
||||
}
|
||||
|
||||
function tmuxSocketArgs(socketName: string): readonly string[] {
|
||||
return socketName === '' ? [] : ['-L', socketName];
|
||||
}
|
||||
|
||||
function sameStringArray(left: readonly string[], right: readonly string[]): boolean {
|
||||
return (
|
||||
left.length === right.length &&
|
||||
left.every((value: string, index: number): boolean => value === right[index])
|
||||
);
|
||||
}
|
||||
@@ -1,279 +0,0 @@
|
||||
import {
|
||||
chmod,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
rename,
|
||||
rm,
|
||||
stat,
|
||||
symlink,
|
||||
writeFile,
|
||||
} from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
AgentEnvBoundaryError,
|
||||
parseAgentEnvironment,
|
||||
renderGeneratedAgentEnvironment,
|
||||
writeAgentEnvironmentProjection,
|
||||
} from './generated-env-boundary.js';
|
||||
|
||||
const generatedValues = {
|
||||
MOSAIC_AGENT_NAME: 'coder0',
|
||||
MOSAIC_AGENT_CLASS: 'code',
|
||||
MOSAIC_AGENT_RUNTIME: 'pi',
|
||||
MOSAIC_AGENT_MODEL: 'openai-codex/gpt-5.6-sol',
|
||||
MOSAIC_AGENT_REASONING: 'high',
|
||||
MOSAIC_AGENT_TOOL_POLICY: 'code',
|
||||
MOSAIC_AGENT_WORKDIR: '/srv/mosaic',
|
||||
MOSAIC_TMUX_SOCKET: 'mosaic-fleet',
|
||||
};
|
||||
|
||||
describe('generated fleet agent environment boundary', (): void => {
|
||||
let cleanup: string | undefined;
|
||||
|
||||
afterEach(async (): Promise<void> => {
|
||||
if (cleanup) {
|
||||
await rm(cleanup, { recursive: true, force: true });
|
||||
cleanup = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
it('renders a deterministic complete generated projection', (): void => {
|
||||
expect(renderGeneratedAgentEnvironment(generatedValues)).toBe(
|
||||
[
|
||||
'MOSAIC_AGENT_NAME=coder0',
|
||||
'MOSAIC_AGENT_CLASS=code',
|
||||
'MOSAIC_AGENT_RUNTIME=pi',
|
||||
'MOSAIC_AGENT_MODEL=openai-codex/gpt-5.6-sol',
|
||||
'MOSAIC_AGENT_REASONING=high',
|
||||
'MOSAIC_AGENT_TOOL_POLICY=code',
|
||||
'MOSAIC_AGENT_WORKDIR=/srv/mosaic',
|
||||
'MOSAIC_TMUX_SOCKET=mosaic-fleet',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['generated-key shadowing', 'MOSAIC_AGENT_RUNTIME=codex\n'],
|
||||
['unknown key', 'UNRELATED_SETTING=value\n'],
|
||||
['arbitrary command', 'MOSAIC_AGENT_COMMAND=mosaic yolo codex\n'],
|
||||
['sensitive key', 'MOSAIC_AGENT_TOKEN=do-not-log-me\n'],
|
||||
['malformed syntax', 'export MOSAIC_RUNTIME_BIN=/opt/bin\n'],
|
||||
['duplicate keys', 'MOSAIC_RUNTIME_BIN=/opt/bin\nMOSAIC_RUNTIME_BIN=/other/bin\n'],
|
||||
])('rejects local %s without echoing values', (_name: string, source: string): void => {
|
||||
let error: unknown;
|
||||
try {
|
||||
parseAgentEnvironment(source, 'local');
|
||||
} catch (caught: unknown) {
|
||||
error = caught;
|
||||
}
|
||||
|
||||
expect(error).toBeInstanceOf(AgentEnvBoundaryError);
|
||||
expect(String(error)).not.toContain('mosaic yolo codex');
|
||||
expect(String(error)).not.toContain('do-not-log-me');
|
||||
expect(String(error)).toMatch(/key=.*sha256=/);
|
||||
});
|
||||
|
||||
it('rejects unsafe generated paths before any launch consumer can use them', (): void => {
|
||||
expect((): void => {
|
||||
renderGeneratedAgentEnvironment({
|
||||
...generatedValues,
|
||||
MOSAIC_AGENT_WORKDIR: '../outside',
|
||||
});
|
||||
}).toThrow(AgentEnvBoundaryError);
|
||||
});
|
||||
|
||||
it('creates missing managed directories privately before writing a projection', async (): Promise<void> => {
|
||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-generated-env-'));
|
||||
const mosaicHome = join(cleanup, 'mosaic');
|
||||
const agentEnvDir = join(mosaicHome, 'fleet', 'agents');
|
||||
|
||||
const result = await writeAgentEnvironmentProjection({
|
||||
mosaicHome,
|
||||
agentEnvDir,
|
||||
agentName: 'coder0',
|
||||
generated: generatedValues,
|
||||
});
|
||||
|
||||
for (const directory of [mosaicHome, join(mosaicHome, 'fleet'), agentEnvDir]) {
|
||||
expect((await stat(directory)).mode & 0o777).toBe(0o700);
|
||||
}
|
||||
expect((await stat(result.generatedPath)).mode & 0o777).toBe(0o600);
|
||||
});
|
||||
|
||||
it('regenerates desired keys, relocates safe legacy local data, and quarantines forbidden legacy input', async (): Promise<void> => {
|
||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-generated-env-'));
|
||||
const mosaicHome = join(cleanup, 'mosaic');
|
||||
const agentEnvDir = join(mosaicHome, 'fleet', 'agents');
|
||||
await mkdir(agentEnvDir, { recursive: true, mode: 0o700 });
|
||||
await writeFile(
|
||||
join(agentEnvDir, 'coder0.env'),
|
||||
[
|
||||
'MOSAIC_AGENT_NAME=stale-name',
|
||||
'MOSAIC_AGENT_RUNTIME=codex',
|
||||
'MOSAIC_RUNTIME_BIN=/opt/mosaic/bin',
|
||||
'MOSAIC_AGENT_COMMAND=mosaic yolo codex --dangerous',
|
||||
'',
|
||||
].join('\n'),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
|
||||
const result = await writeAgentEnvironmentProjection({
|
||||
mosaicHome,
|
||||
agentEnvDir,
|
||||
agentName: 'coder0',
|
||||
generated: generatedValues,
|
||||
});
|
||||
|
||||
expect(await readFile(result.generatedPath, 'utf8')).toBe(
|
||||
renderGeneratedAgentEnvironment(generatedValues),
|
||||
);
|
||||
expect(await readFile(result.localPath, 'utf8')).toBe('MOSAIC_RUNTIME_BIN=/opt/mosaic/bin\n');
|
||||
const quarantine = await readFile(result.quarantinePath!, 'utf8');
|
||||
expect(quarantine).toContain('MOSAIC_AGENT_COMMAND=mosaic yolo codex --dangerous');
|
||||
await expect(readFile(join(agentEnvDir, 'coder0.env'), 'utf8')).rejects.toThrow();
|
||||
expect((await stat(result.generatedPath)).mode & 0o077).toBe(0);
|
||||
expect((await stat(result.localPath)).mode & 0o077).toBe(0);
|
||||
expect(result.diagnostics).toEqual([expect.objectContaining({ key: 'MOSAIC_AGENT_COMMAND' })]);
|
||||
expect(JSON.stringify(result.diagnostics)).not.toContain('mosaic yolo codex --dangerous');
|
||||
});
|
||||
|
||||
it('fails closed on an existing local override with unsafe permissions', async (): Promise<void> => {
|
||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-generated-env-'));
|
||||
const mosaicHome = join(cleanup, 'mosaic');
|
||||
const agentEnvDir = join(mosaicHome, 'fleet', 'agents');
|
||||
await mkdir(agentEnvDir, { recursive: true, mode: 0o700 });
|
||||
const localPath = join(agentEnvDir, 'coder0.env.local');
|
||||
await writeFile(localPath, 'MOSAIC_RUNTIME_BIN=/opt/mosaic/bin\n', { mode: 0o600 });
|
||||
await chmod(localPath, 0o644);
|
||||
|
||||
await expect(
|
||||
writeAgentEnvironmentProjection({
|
||||
mosaicHome,
|
||||
agentEnvDir,
|
||||
agentName: 'coder0',
|
||||
generated: generatedValues,
|
||||
}),
|
||||
).rejects.toThrow(/permissions/i);
|
||||
});
|
||||
|
||||
it('rejects an existing group/world-accessible projection directory before reading it', async (): Promise<void> => {
|
||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-generated-env-'));
|
||||
const mosaicHome = join(cleanup, 'mosaic');
|
||||
const agentEnvDir = join(mosaicHome, 'fleet', 'agents');
|
||||
const localPath = join(agentEnvDir, 'coder0.env.local');
|
||||
const generatedPath = join(agentEnvDir, 'coder0.env.generated');
|
||||
await mkdir(agentEnvDir, { recursive: true, mode: 0o700 });
|
||||
await writeFile(localPath, 'MOSAIC_RUNTIME_BIN=/opt/mosaic/bin\n', { mode: 0o600 });
|
||||
await chmod(agentEnvDir, 0o777);
|
||||
|
||||
await expect(
|
||||
writeAgentEnvironmentProjection({
|
||||
mosaicHome,
|
||||
agentEnvDir,
|
||||
agentName: 'coder0',
|
||||
generated: generatedValues,
|
||||
}),
|
||||
).rejects.toThrow(/unsafe-permissions/i);
|
||||
|
||||
await expect(readFile(localPath, 'utf8')).resolves.toBe('MOSAIC_RUNTIME_BIN=/opt/mosaic/bin\n');
|
||||
await expect(readFile(generatedPath, 'utf8')).rejects.toThrow();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['MOSAIC_HOME', 'symlink'],
|
||||
['MOSAIC_HOME/fleet', 'symlink'],
|
||||
['MOSAIC_HOME/fleet/agents', 'symlink'],
|
||||
['MOSAIC_HOME', 'group/world-writable'],
|
||||
['MOSAIC_HOME/fleet', 'group/world-writable'],
|
||||
['MOSAIC_HOME/fleet/agents', 'group/world-writable'],
|
||||
])(
|
||||
'rejects a %s %s managed ancestor before any projection mutation',
|
||||
async (ancestor: string, hazard: string): Promise<void> => {
|
||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-generated-env-'));
|
||||
const mosaicHome = join(cleanup, 'mosaic');
|
||||
const fleetDir = join(mosaicHome, 'fleet');
|
||||
const agentEnvDir = join(fleetDir, 'agents');
|
||||
const generatedPath = join(agentEnvDir, 'coder0.env.generated');
|
||||
const localPath = join(agentEnvDir, 'coder0.env.local');
|
||||
const legacyPath = join(agentEnvDir, 'coder0.env');
|
||||
const quarantinePath = join(agentEnvDir, 'coder0.env.quarantine');
|
||||
const managedPaths: Record<string, string> = {
|
||||
MOSAIC_HOME: mosaicHome,
|
||||
'MOSAIC_HOME/fleet': fleetDir,
|
||||
'MOSAIC_HOME/fleet/agents': agentEnvDir,
|
||||
};
|
||||
const unsafePath = managedPaths[ancestor];
|
||||
if (unsafePath === undefined) throw new Error(`Unknown managed ancestor: ${ancestor}`);
|
||||
|
||||
await mkdir(agentEnvDir, { recursive: true, mode: 0o700 });
|
||||
await chmod(mosaicHome, 0o700);
|
||||
await chmod(fleetDir, 0o700);
|
||||
await chmod(agentEnvDir, 0o700);
|
||||
await writeFile(generatedPath, 'generated-before\n', { mode: 0o600 });
|
||||
await writeFile(localPath, 'MOSAIC_RUNTIME_BIN=/safe/runtime\n', { mode: 0o600 });
|
||||
await writeFile(legacyPath, 'MOSAIC_AGENT_COMMAND=legacy-command\n', { mode: 0o600 });
|
||||
|
||||
if (hazard === 'symlink') {
|
||||
const targetPath = `${unsafePath}-target`;
|
||||
await rename(unsafePath, targetPath);
|
||||
await symlink(targetPath, unsafePath, 'dir');
|
||||
} else {
|
||||
await chmod(unsafePath, 0o777);
|
||||
}
|
||||
|
||||
await expect(
|
||||
writeAgentEnvironmentProjection({
|
||||
mosaicHome,
|
||||
agentEnvDir,
|
||||
agentName: 'coder0',
|
||||
generated: generatedValues,
|
||||
}),
|
||||
).rejects.toThrow(/unsafe-(directory|permissions)/i);
|
||||
|
||||
await expect(readFile(generatedPath, 'utf8')).resolves.toBe('generated-before\n');
|
||||
await expect(readFile(localPath, 'utf8')).resolves.toBe('MOSAIC_RUNTIME_BIN=/safe/runtime\n');
|
||||
await expect(readFile(legacyPath, 'utf8')).resolves.toBe(
|
||||
'MOSAIC_AGENT_COMMAND=legacy-command\n',
|
||||
);
|
||||
await expect(readFile(quarantinePath, 'utf8')).rejects.toThrow();
|
||||
},
|
||||
);
|
||||
|
||||
it('rejects a symlinked projection directory without mutating its target', async (): Promise<void> => {
|
||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-generated-env-'));
|
||||
const mosaicHome = join(cleanup, 'mosaic');
|
||||
const targetDirectory = join(cleanup, 'target');
|
||||
const linkedDirectory = join(mosaicHome, 'fleet', 'agents');
|
||||
const legacyPath = join(targetDirectory, 'coder0.env');
|
||||
const generatedPath = join(targetDirectory, 'coder0.env.generated');
|
||||
const localPath = join(targetDirectory, 'coder0.env.local');
|
||||
const quarantinePath = join(targetDirectory, 'coder0.env.quarantine');
|
||||
await mkdir(join(mosaicHome, 'fleet'), { recursive: true, mode: 0o700 });
|
||||
await mkdir(targetDirectory, { mode: 0o700 });
|
||||
await writeFile(legacyPath, 'MOSAIC_AGENT_COMMAND=legacy-command\n', { mode: 0o600 });
|
||||
await writeFile(generatedPath, 'generated-before\n', { mode: 0o600 });
|
||||
await writeFile(localPath, 'local-before\n', { mode: 0o600 });
|
||||
await writeFile(quarantinePath, 'quarantine-before\n', { mode: 0o600 });
|
||||
await symlink(targetDirectory, linkedDirectory, 'dir');
|
||||
|
||||
await expect(
|
||||
writeAgentEnvironmentProjection({
|
||||
mosaicHome,
|
||||
agentEnvDir: linkedDirectory,
|
||||
agentName: 'coder0',
|
||||
generated: generatedValues,
|
||||
}),
|
||||
).rejects.toThrow(/unsafe-directory/i);
|
||||
|
||||
await expect(readFile(legacyPath, 'utf8')).resolves.toBe(
|
||||
'MOSAIC_AGENT_COMMAND=legacy-command\n',
|
||||
);
|
||||
await expect(readFile(generatedPath, 'utf8')).resolves.toBe('generated-before\n');
|
||||
await expect(readFile(localPath, 'utf8')).resolves.toBe('local-before\n');
|
||||
await expect(readFile(quarantinePath, 'utf8')).resolves.toBe('quarantine-before\n');
|
||||
});
|
||||
});
|
||||
@@ -1,532 +0,0 @@
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { chmod, lstat, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
|
||||
export type AgentEnvironmentKind = 'generated' | 'local';
|
||||
|
||||
export interface AgentEnvironmentDiagnostic {
|
||||
readonly code: string;
|
||||
readonly key: string;
|
||||
readonly sha256: string;
|
||||
}
|
||||
|
||||
export interface AgentEnvironmentProjectionOptions {
|
||||
readonly mosaicHome: string;
|
||||
readonly agentEnvDir: string;
|
||||
readonly agentName: string;
|
||||
readonly generated: Readonly<Record<string, string>>;
|
||||
}
|
||||
|
||||
export interface AgentGeneratedProjectionDeletionOptions {
|
||||
readonly mosaicHome: string;
|
||||
readonly agentEnvDir: string;
|
||||
readonly agentName: string;
|
||||
}
|
||||
|
||||
export interface AgentEnvironmentProjectionResult {
|
||||
readonly generatedPath: string;
|
||||
readonly localPath: string;
|
||||
readonly quarantinePath?: string;
|
||||
readonly diagnostics: readonly AgentEnvironmentDiagnostic[];
|
||||
}
|
||||
|
||||
/** A projection fully validated without changing managed files. */
|
||||
export interface PreparedAgentEnvironmentProjection {
|
||||
readonly mosaicHome: string;
|
||||
readonly agentEnvDir: string;
|
||||
readonly generatedPath: string;
|
||||
readonly localPath: string;
|
||||
readonly legacyPath: string;
|
||||
readonly generated: string;
|
||||
readonly local: string;
|
||||
readonly legacy?: string;
|
||||
readonly quarantinePath?: string;
|
||||
readonly diagnostics: readonly AgentEnvironmentDiagnostic[];
|
||||
}
|
||||
|
||||
export class AgentEnvBoundaryError extends Error {
|
||||
readonly diagnostic: AgentEnvironmentDiagnostic;
|
||||
|
||||
constructor(code: string, key: string, value: string) {
|
||||
const diagnostic: AgentEnvironmentDiagnostic = {
|
||||
code,
|
||||
key,
|
||||
sha256: hashValue(value),
|
||||
};
|
||||
super(`Agent environment rejected: code=${code} key=${key} sha256=${diagnostic.sha256}`);
|
||||
this.name = 'AgentEnvBoundaryError';
|
||||
this.diagnostic = diagnostic;
|
||||
}
|
||||
}
|
||||
|
||||
export const GENERATED_AGENT_ENV_KEYS = [
|
||||
'MOSAIC_AGENT_NAME',
|
||||
'MOSAIC_AGENT_CLASS',
|
||||
'MOSAIC_AGENT_RUNTIME',
|
||||
'MOSAIC_AGENT_MODEL',
|
||||
'MOSAIC_AGENT_REASONING',
|
||||
'MOSAIC_AGENT_TOOL_POLICY',
|
||||
'MOSAIC_AGENT_WORKDIR',
|
||||
'MOSAIC_TMUX_SOCKET',
|
||||
] as const;
|
||||
|
||||
export const LOCAL_AGENT_ENV_KEYS = [
|
||||
'MOSAIC_RUNTIME_BIN',
|
||||
'MOSAIC_HEARTBEAT_RUN_DIR',
|
||||
'MOSAIC_HEARTBEAT_INTERVAL',
|
||||
'MOSAIC_CLAUDE_JSON',
|
||||
'CLAUDE_CONFIG_DIR',
|
||||
] as const;
|
||||
|
||||
const GENERATED_KEY_SET = new Set<string>(GENERATED_AGENT_ENV_KEYS);
|
||||
const LOCAL_KEY_SET = new Set<string>(LOCAL_AGENT_ENV_KEYS);
|
||||
const SENSITIVE_KEY = /(?:API[_-]?KEY|AUTH|CREDENTIAL|PASSWORD|PRIVATE|SECRET|TOKEN)/i;
|
||||
const AGENT_NAME = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
|
||||
const POLICY_NAME = /^[a-z][a-z0-9-]*$/;
|
||||
const TMUX_SOCKET = /^[A-Za-z0-9_.-]*$/;
|
||||
const MODEL = /^[A-Za-z0-9._/:+-]*$/;
|
||||
/** Runtime launchers supported by the generated environment contract. */
|
||||
export const GENERATED_AGENT_ENV_SUPPORTED_RUNTIMES = [
|
||||
'claude',
|
||||
'codex',
|
||||
'opencode',
|
||||
'pi',
|
||||
] as const;
|
||||
|
||||
const SUPPORTED_RUNTIMES = new Set<string>(GENERATED_AGENT_ENV_SUPPORTED_RUNTIMES);
|
||||
const REASONING = new Set(['', 'low', 'medium', 'high']);
|
||||
|
||||
/** Parses a strict data-only generated or local environment file without shell evaluation. */
|
||||
export function parseAgentEnvironment(
|
||||
source: string,
|
||||
kind: AgentEnvironmentKind,
|
||||
): Readonly<Record<string, string>> {
|
||||
const values: Record<string, string> = {};
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const line of source.split('\n')) {
|
||||
if (line === '') continue;
|
||||
const match = /^([A-Z][A-Z0-9_]*)=(.*)$/.exec(line);
|
||||
if (!match) throw new AgentEnvBoundaryError('malformed-line', '(malformed)', line);
|
||||
|
||||
const [, key, value] = match;
|
||||
if (key === undefined || value === undefined) {
|
||||
throw new AgentEnvBoundaryError('malformed-line', '(malformed)', line);
|
||||
}
|
||||
if (seen.has(key)) throw new AgentEnvBoundaryError('duplicate-key', key, value);
|
||||
seen.add(key);
|
||||
if (SENSITIVE_KEY.test(key)) throw new AgentEnvBoundaryError('sensitive-key', key, value);
|
||||
if (containsShellSyntax(value)) throw new AgentEnvBoundaryError('unsafe-value', key, value);
|
||||
|
||||
if (kind === 'generated') {
|
||||
if (!GENERATED_KEY_SET.has(key)) throw new AgentEnvBoundaryError('unknown-key', key, value);
|
||||
} else {
|
||||
if (GENERATED_KEY_SET.has(key))
|
||||
throw new AgentEnvBoundaryError('generated-key-shadow', key, value);
|
||||
if (!LOCAL_KEY_SET.has(key)) throw new AgentEnvBoundaryError('unknown-key', key, value);
|
||||
}
|
||||
values[key] = value;
|
||||
}
|
||||
|
||||
if (kind === 'generated') assertGeneratedValues(values);
|
||||
else assertLocalValues(values);
|
||||
return Object.freeze(values);
|
||||
}
|
||||
|
||||
/** Renders the roster-derived generated projection in a stable, complete key order. */
|
||||
export function renderGeneratedAgentEnvironment(values: Readonly<Record<string, string>>): string {
|
||||
const normalized = normalizeGeneratedValues(values);
|
||||
return `${GENERATED_AGENT_ENV_KEYS.map((key): string => `${key}=${normalized[key]}`).join('\n')}\n`;
|
||||
}
|
||||
|
||||
/** Validates all deterministic projection inputs and target state without mutation. */
|
||||
export async function prepareAgentEnvironmentProjection(
|
||||
options: AgentEnvironmentProjectionOptions,
|
||||
): Promise<PreparedAgentEnvironmentProjection> {
|
||||
if (!AGENT_NAME.test(options.agentName)) {
|
||||
throw new AgentEnvBoundaryError('unsafe-agent-name', 'MOSAIC_AGENT_NAME', options.agentName);
|
||||
}
|
||||
|
||||
await validatePrivateProjectionDirectory(options.mosaicHome, options.agentEnvDir);
|
||||
const generatedPath = join(options.agentEnvDir, `${options.agentName}.env.generated`);
|
||||
const localPath = join(options.agentEnvDir, `${options.agentName}.env.local`);
|
||||
const legacyPath = join(options.agentEnvDir, `${options.agentName}.env`);
|
||||
const generated = renderGeneratedAgentEnvironment(options.generated);
|
||||
await assertPrivateRegularFileIfPresent(generatedPath);
|
||||
const currentLocal = await readOptionalPrivateFile(localPath);
|
||||
const parsedLocal =
|
||||
currentLocal === undefined ? {} : parseAgentEnvironment(currentLocal, 'local');
|
||||
const legacy = await readOptionalPrivateFile(legacyPath);
|
||||
const legacyDisposition =
|
||||
legacy === undefined ? emptyLegacyDisposition() : classifyLegacyEnvironment(legacy);
|
||||
|
||||
for (const [key, value] of Object.entries(legacyDisposition.localValues)) {
|
||||
if (parsedLocal[key] !== undefined && parsedLocal[key] !== value) {
|
||||
throw new AgentEnvBoundaryError('legacy-local-conflict', key, value);
|
||||
}
|
||||
}
|
||||
|
||||
const local = renderLocalAgentEnvironment({ ...legacyDisposition.localValues, ...parsedLocal });
|
||||
const quarantinePath = legacyDisposition.diagnostics.length
|
||||
? join(options.agentEnvDir, `${options.agentName}.env.quarantine`)
|
||||
: undefined;
|
||||
if (quarantinePath !== undefined) {
|
||||
const existingQuarantine = await readOptionalPrivateFile(quarantinePath);
|
||||
if (existingQuarantine !== undefined) {
|
||||
throw new AgentEnvBoundaryError('quarantine-exists', '(quarantine)', existingQuarantine);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
mosaicHome: options.mosaicHome,
|
||||
agentEnvDir: options.agentEnvDir,
|
||||
generatedPath,
|
||||
localPath,
|
||||
legacyPath,
|
||||
generated,
|
||||
local,
|
||||
...(legacy === undefined ? {} : { legacy }),
|
||||
...(quarantinePath === undefined ? {} : { quarantinePath }),
|
||||
diagnostics: legacyDisposition.diagnostics,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates only the exact generated projection eligible for a roster delete.
|
||||
* Local overrides, legacy input, and quarantine records are operator-retained and
|
||||
* intentionally excluded from delete validation and cleanup.
|
||||
*/
|
||||
export async function prepareAgentGeneratedProjectionDeletion(
|
||||
options: AgentGeneratedProjectionDeletionOptions,
|
||||
): Promise<string> {
|
||||
if (!AGENT_NAME.test(options.agentName)) {
|
||||
throw new AgentEnvBoundaryError('unsafe-agent-name', 'MOSAIC_AGENT_NAME', options.agentName);
|
||||
}
|
||||
await validatePrivateProjectionDirectory(options.mosaicHome, options.agentEnvDir);
|
||||
const generatedPath = join(options.agentEnvDir, `${options.agentName}.env.generated`);
|
||||
await assertPrivateRegularFileIfPresent(generatedPath);
|
||||
return generatedPath;
|
||||
}
|
||||
|
||||
/** Applies a previously prepared deterministic projection. */
|
||||
export async function applyPreparedAgentEnvironmentProjection(
|
||||
prepared: PreparedAgentEnvironmentProjection,
|
||||
): Promise<AgentEnvironmentProjectionResult> {
|
||||
await ensurePrivateProjectionDirectory(prepared.mosaicHome, prepared.agentEnvDir);
|
||||
await writePrivateAtomically(prepared.generatedPath, prepared.generated);
|
||||
if (prepared.local !== '') await writePrivateAtomically(prepared.localPath, prepared.local);
|
||||
if (prepared.quarantinePath !== undefined && prepared.legacy !== undefined) {
|
||||
await writePrivateAtomically(prepared.quarantinePath, prepared.legacy);
|
||||
}
|
||||
if (prepared.legacy !== undefined) await unlink(prepared.legacyPath);
|
||||
|
||||
return {
|
||||
generatedPath: prepared.generatedPath,
|
||||
localPath: prepared.localPath,
|
||||
...(prepared.quarantinePath === undefined ? {} : { quarantinePath: prepared.quarantinePath }),
|
||||
diagnostics: prepared.diagnostics,
|
||||
};
|
||||
}
|
||||
|
||||
/** Writes deterministic projection files and explicitly removes legacy `.env` authority. */
|
||||
export async function writeAgentEnvironmentProjection(
|
||||
options: AgentEnvironmentProjectionOptions,
|
||||
): Promise<AgentEnvironmentProjectionResult> {
|
||||
return applyPreparedAgentEnvironmentProjection(await prepareAgentEnvironmentProjection(options));
|
||||
}
|
||||
|
||||
/** Creates the canonical managed roster path with private fresh-chain permissions. */
|
||||
export async function writeManagedFleetRoster(
|
||||
mosaicHome: string,
|
||||
rosterPath: string,
|
||||
content: string,
|
||||
): Promise<void> {
|
||||
const fleetDir = join(mosaicHome, 'fleet');
|
||||
const expectedRosterPath = join(fleetDir, 'roster.yaml');
|
||||
if (resolve(rosterPath) !== resolve(expectedRosterPath)) {
|
||||
throw new AgentEnvBoundaryError('unsafe-file', '(roster)', rosterPath);
|
||||
}
|
||||
await ensureManagedDirectory(mosaicHome, false);
|
||||
await ensureManagedDirectory(fleetDir, false);
|
||||
await writePrivateAtomically(rosterPath, content);
|
||||
}
|
||||
|
||||
/** Ensures the private install-derived identity used to own a named tmux server. */
|
||||
export async function ensureFleetHolderIdentity(mosaicHome: string): Promise<string> {
|
||||
const fleetDir = join(mosaicHome, 'fleet');
|
||||
const runDir = join(fleetDir, 'run');
|
||||
const identityPath = join(runDir, 'holder-owner');
|
||||
await ensureManagedDirectory(mosaicHome, false);
|
||||
await ensureManagedDirectory(fleetDir, false);
|
||||
await ensureManagedDirectory(runDir, true);
|
||||
const existing = await readOptionalPrivateFile(identityPath);
|
||||
if (existing !== undefined) {
|
||||
const identity = existing.trim();
|
||||
if (!/^[a-f0-9-]{36}$/.test(identity)) {
|
||||
throw new AgentEnvBoundaryError('unsafe-owner-identity', '(holder-owner)', existing);
|
||||
}
|
||||
return identity;
|
||||
}
|
||||
const identity = randomUUID();
|
||||
await writePrivateAtomically(identityPath, `${identity}\n`);
|
||||
return identity;
|
||||
}
|
||||
|
||||
function normalizeGeneratedValues(
|
||||
values: Readonly<Record<string, string>>,
|
||||
): Readonly<Record<string, string>> {
|
||||
const normalized: Record<string, string> = {};
|
||||
for (const key of GENERATED_AGENT_ENV_KEYS) {
|
||||
const value = values[key];
|
||||
if (value === undefined) throw new AgentEnvBoundaryError('missing-key', key, '');
|
||||
normalized[key] = value;
|
||||
}
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
if (!GENERATED_KEY_SET.has(key)) throw new AgentEnvBoundaryError('unknown-key', key, value);
|
||||
}
|
||||
assertGeneratedValues(normalized);
|
||||
return Object.freeze(normalized);
|
||||
}
|
||||
|
||||
function renderLocalAgentEnvironment(values: Readonly<Record<string, string>>): string {
|
||||
if (Object.keys(values).length === 0) return '';
|
||||
const parsed = parseAgentEnvironment(
|
||||
Object.entries(values)
|
||||
.sort(([left], [right]): number => left.localeCompare(right))
|
||||
.map(([key, value]): string => `${key}=${value}`)
|
||||
.join('\n'),
|
||||
'local',
|
||||
);
|
||||
return `${Object.entries(parsed)
|
||||
.sort(([left], [right]): number => left.localeCompare(right))
|
||||
.map(([key, value]): string => `${key}=${value}`)
|
||||
.join('\n')}\n`;
|
||||
}
|
||||
|
||||
function assertGeneratedValues(values: Readonly<Record<string, string>>): void {
|
||||
for (const key of GENERATED_AGENT_ENV_KEYS) {
|
||||
const value = values[key];
|
||||
if (value === undefined) throw new AgentEnvBoundaryError('missing-key', key, '');
|
||||
}
|
||||
const name = requiredGeneratedValue(values, 'MOSAIC_AGENT_NAME');
|
||||
const className = requiredGeneratedValue(values, 'MOSAIC_AGENT_CLASS');
|
||||
const runtime = requiredGeneratedValue(values, 'MOSAIC_AGENT_RUNTIME');
|
||||
const model = requiredGeneratedValue(values, 'MOSAIC_AGENT_MODEL');
|
||||
const reasoning = requiredGeneratedValue(values, 'MOSAIC_AGENT_REASONING');
|
||||
const toolPolicy = requiredGeneratedValue(values, 'MOSAIC_AGENT_TOOL_POLICY');
|
||||
const workingDirectory = requiredGeneratedValue(values, 'MOSAIC_AGENT_WORKDIR');
|
||||
const socket = requiredGeneratedValue(values, 'MOSAIC_TMUX_SOCKET');
|
||||
|
||||
if (!AGENT_NAME.test(name))
|
||||
throw new AgentEnvBoundaryError('unsafe-agent-name', 'MOSAIC_AGENT_NAME', name);
|
||||
if (!POLICY_NAME.test(className)) {
|
||||
throw new AgentEnvBoundaryError('unsafe-class', 'MOSAIC_AGENT_CLASS', className);
|
||||
}
|
||||
if (!SUPPORTED_RUNTIMES.has(runtime)) {
|
||||
throw new AgentEnvBoundaryError('unsupported-runtime', 'MOSAIC_AGENT_RUNTIME', runtime);
|
||||
}
|
||||
if (!MODEL.test(model))
|
||||
throw new AgentEnvBoundaryError('unsafe-model', 'MOSAIC_AGENT_MODEL', model);
|
||||
if (!REASONING.has(reasoning)) {
|
||||
throw new AgentEnvBoundaryError('unsupported-reasoning', 'MOSAIC_AGENT_REASONING', reasoning);
|
||||
}
|
||||
if (toolPolicy !== '' && !POLICY_NAME.test(toolPolicy)) {
|
||||
throw new AgentEnvBoundaryError('unsafe-tool-policy', 'MOSAIC_AGENT_TOOL_POLICY', toolPolicy);
|
||||
}
|
||||
if (!isSafeAbsolutePath(workingDirectory)) {
|
||||
throw new AgentEnvBoundaryError('unsafe-path', 'MOSAIC_AGENT_WORKDIR', workingDirectory);
|
||||
}
|
||||
if (!TMUX_SOCKET.test(socket)) {
|
||||
throw new AgentEnvBoundaryError('unsafe-socket', 'MOSAIC_TMUX_SOCKET', socket);
|
||||
}
|
||||
}
|
||||
|
||||
function requiredGeneratedValue(values: Readonly<Record<string, string>>, key: string): string {
|
||||
const value = values[key];
|
||||
if (value === undefined) throw new AgentEnvBoundaryError('missing-key', key, '');
|
||||
return value;
|
||||
}
|
||||
|
||||
function assertLocalValues(values: Readonly<Record<string, string>>): void {
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
if (key === 'MOSAIC_HEARTBEAT_INTERVAL') {
|
||||
if (!/^[1-9][0-9]*$/.test(value)) {
|
||||
throw new AgentEnvBoundaryError('invalid-interval', key, value);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!isSafeAbsolutePath(value)) throw new AgentEnvBoundaryError('unsafe-path', key, value);
|
||||
}
|
||||
}
|
||||
|
||||
function isSafeAbsolutePath(value: string): boolean {
|
||||
return (
|
||||
value.startsWith('/') && !value.split('/').includes('..') && !/[\s"'`$\\;&|<>(){}]/.test(value)
|
||||
);
|
||||
}
|
||||
|
||||
function containsShellSyntax(value: string): boolean {
|
||||
return /["'`$\\;&|<>(){}]/.test(value);
|
||||
}
|
||||
|
||||
interface LegacyDisposition {
|
||||
readonly localValues: Readonly<Record<string, string>>;
|
||||
readonly diagnostics: readonly AgentEnvironmentDiagnostic[];
|
||||
}
|
||||
|
||||
function emptyLegacyDisposition(): LegacyDisposition {
|
||||
return { localValues: {}, diagnostics: [] };
|
||||
}
|
||||
|
||||
function classifyLegacyEnvironment(source: string): LegacyDisposition {
|
||||
const localValues: Record<string, string> = {};
|
||||
const diagnostics: AgentEnvironmentDiagnostic[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const line of source.split('\n')) {
|
||||
if (line === '') continue;
|
||||
const match = /^([A-Z][A-Z0-9_]*)=(.*)$/.exec(line);
|
||||
if (!match || match[1] === undefined || match[2] === undefined) {
|
||||
diagnostics.push(diagnostic('malformed-line', '(malformed)', line));
|
||||
continue;
|
||||
}
|
||||
const [, key, value] = match;
|
||||
if (seen.has(key)) {
|
||||
diagnostics.push(diagnostic('duplicate-key', key, value));
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
if (GENERATED_KEY_SET.has(key)) continue;
|
||||
try {
|
||||
parseAgentEnvironment(`${key}=${value}\n`, 'local');
|
||||
localValues[key] = value;
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof AgentEnvBoundaryError) diagnostics.push(error.diagnostic);
|
||||
else throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return { localValues: Object.freeze(localValues), diagnostics: Object.freeze(diagnostics) };
|
||||
}
|
||||
|
||||
function diagnostic(code: string, key: string, value: string): AgentEnvironmentDiagnostic {
|
||||
return { code, key, sha256: hashValue(value) };
|
||||
}
|
||||
|
||||
function hashValue(value: string): string {
|
||||
return createHash('sha256').update(value).digest('hex');
|
||||
}
|
||||
|
||||
async function readOptionalPrivateFile(path: string): Promise<string | undefined> {
|
||||
try {
|
||||
await assertPrivateRegularFile(path);
|
||||
return await readFile(path, 'utf8');
|
||||
} catch (error: unknown) {
|
||||
if (isMissingFile(error)) return undefined;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function isMissingFile(error: unknown): boolean {
|
||||
return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT';
|
||||
}
|
||||
|
||||
async function validatePrivateProjectionDirectory(
|
||||
mosaicHome: string,
|
||||
agentEnvDir: string,
|
||||
): Promise<void> {
|
||||
const fleetDir = join(mosaicHome, 'fleet');
|
||||
const expectedAgentEnvDir = join(fleetDir, 'agents');
|
||||
if (resolve(agentEnvDir) !== resolve(expectedAgentEnvDir)) {
|
||||
throw new AgentEnvBoundaryError('unsafe-directory', '(directory)', agentEnvDir);
|
||||
}
|
||||
await assertManagedDirectoryIfPresent(mosaicHome, false);
|
||||
await assertManagedDirectoryIfPresent(fleetDir, false);
|
||||
await assertManagedDirectoryIfPresent(agentEnvDir, true);
|
||||
}
|
||||
|
||||
async function ensurePrivateProjectionDirectory(
|
||||
mosaicHome: string,
|
||||
agentEnvDir: string,
|
||||
): Promise<void> {
|
||||
await validatePrivateProjectionDirectory(mosaicHome, agentEnvDir);
|
||||
const fleetDir = join(mosaicHome, 'fleet');
|
||||
await ensureManagedDirectory(mosaicHome, false);
|
||||
await ensureManagedDirectory(fleetDir, false);
|
||||
await ensureManagedDirectory(agentEnvDir, true);
|
||||
}
|
||||
|
||||
async function ensureManagedDirectory(path: string, privateDirectory: boolean): Promise<void> {
|
||||
let created = false;
|
||||
try {
|
||||
await lstat(path);
|
||||
} catch (error: unknown) {
|
||||
if (!isMissingFile(error)) throw error;
|
||||
try {
|
||||
await mkdir(path, { recursive: true, mode: 0o700 });
|
||||
created = true;
|
||||
} catch (mkdirError: unknown) {
|
||||
if (!isAlreadyExists(mkdirError)) throw mkdirError;
|
||||
}
|
||||
}
|
||||
|
||||
await assertManagedDirectory(path, privateDirectory);
|
||||
if (created) {
|
||||
await chmod(path, 0o700);
|
||||
await assertManagedDirectory(path, privateDirectory);
|
||||
}
|
||||
}
|
||||
|
||||
function isAlreadyExists(error: unknown): boolean {
|
||||
return typeof error === 'object' && error !== null && 'code' in error && error.code === 'EEXIST';
|
||||
}
|
||||
|
||||
async function assertManagedDirectoryIfPresent(
|
||||
path: string,
|
||||
privateDirectory: boolean,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await assertManagedDirectory(path, privateDirectory);
|
||||
} catch (error: unknown) {
|
||||
if (isMissingFile(error)) return;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function assertManagedDirectory(path: string, privateDirectory: boolean): Promise<void> {
|
||||
const metadata = await lstat(path);
|
||||
if (!metadata.isDirectory()) {
|
||||
throw new AgentEnvBoundaryError('unsafe-directory', '(directory)', path);
|
||||
}
|
||||
if ((metadata.mode & 0o022) !== 0 || (privateDirectory && (metadata.mode & 0o077) !== 0)) {
|
||||
throw new AgentEnvBoundaryError('unsafe-permissions', '(directory)', path);
|
||||
}
|
||||
}
|
||||
|
||||
async function assertPrivateRegularFileIfPresent(path: string): Promise<void> {
|
||||
try {
|
||||
await assertPrivateRegularFile(path);
|
||||
} catch (error: unknown) {
|
||||
if (isMissingFile(error)) return;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function assertPrivateRegularFile(path: string): Promise<void> {
|
||||
const metadata = await lstat(path);
|
||||
if (!metadata.isFile()) throw new AgentEnvBoundaryError('unsafe-file', '(file)', path);
|
||||
if ((metadata.mode & 0o077) !== 0) {
|
||||
throw new AgentEnvBoundaryError('unsafe-permissions', '(file)', path);
|
||||
}
|
||||
}
|
||||
|
||||
async function writePrivateAtomically(path: string, content: string): Promise<void> {
|
||||
try {
|
||||
await assertPrivateRegularFile(path);
|
||||
} catch (error: unknown) {
|
||||
if (!isMissingFile(error)) throw error;
|
||||
}
|
||||
const temporaryPath = join(dirname(path), `.${randomUUID()}.tmp`);
|
||||
await writeFile(temporaryPath, content, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
|
||||
await rename(temporaryPath, path);
|
||||
}
|
||||
@@ -77,25 +77,12 @@ describe('readPersonaContractBlock — launch-time persona injection (A3b)', ()
|
||||
});
|
||||
|
||||
it('injects an override-only (user-added) persona with no baseline at all', () => {
|
||||
seedOverride(home, 'mascot', '# Mascot\n\n(`class: mascot`)\n\nCUSTOM-ROLE.\n');
|
||||
const block = readPersonaContractBlock(home, 'mascot');
|
||||
expect(block).toContain('# Persona Contract (mascot)');
|
||||
seedOverride(home, 'reviewer', '# Reviewer\n\n(`class: reviewer`)\n\nCUSTOM-ROLE.\n');
|
||||
const block = readPersonaContractBlock(home, 'reviewer');
|
||||
expect(block).toContain('# Persona Contract (reviewer)');
|
||||
expect(block).toContain('CUSTOM-ROLE');
|
||||
});
|
||||
|
||||
it('canonicalizes an approved alias before launch-time override lookup', () => {
|
||||
seedBaseline(home, 'code', '# Code\n\n(`class: code`)\n\nCANONICAL-CODE.\n');
|
||||
seedOverride(
|
||||
home,
|
||||
'implementer',
|
||||
'# Legacy implementer\n\n(`class: implementer`)\n\nLEGACY-OVERRIDE.\n',
|
||||
);
|
||||
const block = readPersonaContractBlock(home, 'implementer');
|
||||
expect(block).toContain('# Persona Contract (code)');
|
||||
expect(block).toContain('CANONICAL-CODE');
|
||||
expect(block).not.toContain('LEGACY-OVERRIDE');
|
||||
});
|
||||
|
||||
it('no-ops (empty string) when the class is undefined', () => {
|
||||
seedBaseline(home, 'coder', BASELINE_CODER);
|
||||
expect(readPersonaContractBlock(home, undefined)).toBe('');
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
ROSTER_V2_JSON_SCHEMA,
|
||||
RosterV2ValidationError,
|
||||
parseRosterV2,
|
||||
renderRosterV2Yaml,
|
||||
validateRosterV2Semantics,
|
||||
} from './roster-v2.js';
|
||||
|
||||
const validRoster = `
|
||||
@@ -43,127 +40,6 @@ agents:
|
||||
yolo: true
|
||||
`;
|
||||
|
||||
let semanticTmp: string | undefined;
|
||||
|
||||
afterEach(async (): Promise<void> => {
|
||||
if (semanticTmp) await rm(semanticTmp, { recursive: true, force: true });
|
||||
semanticTmp = undefined;
|
||||
});
|
||||
|
||||
async function semanticDirs(): Promise<{ rolesDir: string; overrideDir: string }> {
|
||||
semanticTmp = await mkdtemp(join(tmpdir(), 'roster-v2-semantics-'));
|
||||
const rolesDir = join(semanticTmp, 'roles');
|
||||
const overrideDir = join(semanticTmp, 'roles.local');
|
||||
await mkdir(rolesDir, { recursive: true });
|
||||
await mkdir(overrideDir, { recursive: true });
|
||||
for (const klass of [
|
||||
'code',
|
||||
'review',
|
||||
'interaction',
|
||||
'orchestrator',
|
||||
'merge-gate',
|
||||
'validator',
|
||||
'team-leader',
|
||||
]) {
|
||||
await writeFile(join(rolesDir, `${klass}.md`), `# ${klass}\n\n(\`class: ${klass}\`)\n`, 'utf8');
|
||||
}
|
||||
return { rolesDir, overrideDir };
|
||||
}
|
||||
|
||||
function rosterWithClass(klass: string, toolPolicy = klass): string {
|
||||
return validRoster
|
||||
.replace('class: code', `class: ${klass}`)
|
||||
.replace('tool_policy: code', `tool_policy: ${toolPolicy}`);
|
||||
}
|
||||
|
||||
describe('roster v2 semantic validation', (): void => {
|
||||
it.each([
|
||||
['implementer', 'code'],
|
||||
['reviewer', 'review'],
|
||||
['operator-interaction', 'interaction'],
|
||||
])(
|
||||
'canonicalizes requested alias %s while retaining requested and canonical class',
|
||||
async (requested: string, canonical: string) => {
|
||||
const dirs = await semanticDirs();
|
||||
const roster = parseRosterV2(rosterWithClass(requested, requested), 'yaml');
|
||||
const validated = await validateRosterV2Semantics(roster, dirs);
|
||||
expect(validated.agents[0]).toMatchObject({
|
||||
requestedClass: requested,
|
||||
canonicalClass: canonical,
|
||||
canonicalToolPolicy: canonical,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['worker', 'analyst', 'canary'])(
|
||||
'rejects %s when no genuine custom role exists',
|
||||
async (klass: string) => {
|
||||
const dirs = await semanticDirs();
|
||||
await expect(
|
||||
validateRosterV2Semantics(parseRosterV2(rosterWithClass(klass), 'yaml'), dirs),
|
||||
).rejects.toThrow(/unresolved|readable persona/i);
|
||||
},
|
||||
);
|
||||
|
||||
it('accepts a genuine custom roles.local class without protected authority', async () => {
|
||||
const dirs = await semanticDirs();
|
||||
await writeFile(join(dirs.overrideDir, 'worker.md'), '# worker\n\n(`class: worker`)\n', 'utf8');
|
||||
const validated = await validateRosterV2Semantics(
|
||||
parseRosterV2(rosterWithClass('worker'), 'yaml'),
|
||||
dirs,
|
||||
);
|
||||
expect(validated.agents[0]?.authority).toMatchObject({
|
||||
mayMerge: false,
|
||||
mayOrchestrate: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a LIBRARY-only class with no readable resolved persona', async () => {
|
||||
const dirs = await semanticDirs();
|
||||
await writeFile(
|
||||
join(dirs.rolesDir, 'LIBRARY.md'),
|
||||
'| Persona | Purpose |\n| --- | --- |\n| phantom | Missing |\n',
|
||||
'utf8',
|
||||
);
|
||||
await expect(
|
||||
validateRosterV2Semantics(parseRosterV2(rosterWithClass('phantom'), 'yaml'), dirs),
|
||||
).rejects.toThrow(/readable persona/i);
|
||||
});
|
||||
|
||||
it('rejects an unreadable resolved persona', async () => {
|
||||
const dirs = await semanticDirs();
|
||||
await mkdir(join(dirs.overrideDir, 'worker.md'));
|
||||
await expect(
|
||||
validateRosterV2Semantics(parseRosterV2(rosterWithClass('worker'), 'yaml'), dirs),
|
||||
).rejects.toThrow(/readable persona/i);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['merge-gate', 'code'],
|
||||
['validator', 'merge-gate'],
|
||||
['orchestrator', 'interaction'],
|
||||
['team-leader', 'orchestrator'],
|
||||
['interaction', 'orchestrator'],
|
||||
['code', 'merge-gate'],
|
||||
['worker', 'validator'],
|
||||
])(
|
||||
'denies protected class/tool-policy mismatch %s with %s',
|
||||
async (klass: string, toolPolicy: string) => {
|
||||
const dirs = await semanticDirs();
|
||||
if (klass === 'worker') {
|
||||
await writeFile(
|
||||
join(dirs.overrideDir, 'worker.md'),
|
||||
'# worker\n\n(`class: worker`)\n',
|
||||
'utf8',
|
||||
);
|
||||
}
|
||||
await expect(
|
||||
validateRosterV2Semantics(parseRosterV2(rosterWithClass(klass, toolPolicy), 'yaml'), dirs),
|
||||
).rejects.toThrow(/tool policy.*must match|mismatch/i);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('roster v2 structural compiler', (): void => {
|
||||
it('parses YAML into a normalized typed model and renders canonical YAML', (): void => {
|
||||
const roster = parseRosterV2(validRoster, 'yaml');
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user