Compare commits

..

1 Commits

Author SHA1 Message Date
Hermes Agent
12b58c3eaa docs(#769): start KBN-100 schema migration 2026-07-14 16:59:23 -05:00
65 changed files with 1020 additions and 11269 deletions

View File

@@ -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,
],
})

View File

@@ -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();
});
});

View File

@@ -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' });
});
});

View File

@@ -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);
});
});

View File

@@ -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),
});
}

View File

@@ -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];
})
);
}

View File

@@ -284,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

View File

@@ -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.
@@ -63,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)

View File

@@ -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 M0M5 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 | in-progress | 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 | Started 2026-07-14 from `aa5b43b`; one shared resolver only; validator certificate-only; merge-gate 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 |
| id | status | description | issue | agent | repo | branch | depends_on | estimate | notes |
| ---------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ------ | ----------------- | --------------------------------------- | ---------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------- |
| FCM-M0-001 | in-progress | Publish normative PRD requirements/acceptance criteria, this M0M5 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

View File

@@ -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.

View File

@@ -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.

View File

@@ -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

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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,

View File

@@ -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.

View File

@@ -81,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,14 +93,15 @@ No consumer implementation begins before KBN-105. No schema work begins before K
### 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**.
- **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

View File

@@ -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.

View File

@@ -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.

View File

@@ -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).

View 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.

View File

@@ -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 M2M4 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.

View File

@@ -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. |

View File

@@ -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();
});
});

View File

@@ -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}`;
}

View File

@@ -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';

View File

@@ -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

View File

@@ -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
}
]
}

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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"

View File

@@ -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

View File

@@ -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"

View File

@@ -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

View File

@@ -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"

View File

@@ -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();
});

View File

@@ -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;
}

View File

@@ -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'),

View File

@@ -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;
}

View File

@@ -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 });

View File

@@ -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 - ')}`,
);
}
}
// ---------------------------------------------------------------------------

View File

@@ -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';
@@ -284,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',
'',
@@ -327,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 () => {
@@ -1112,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) => {
@@ -1756,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;
}
@@ -3530,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}`;
@@ -3568,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[][] = [];
@@ -3833,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;
}
@@ -3915,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();
@@ -3932,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 () => {

View File

@@ -19,16 +19,6 @@ import * as readline from 'node:readline';
import type { Command } from 'commander';
import YAML from 'yaml';
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';
@@ -519,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[] {
@@ -1539,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
@@ -1947,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.`);
@@ -2038,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
}
@@ -2330,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',
@@ -2350,7 +2347,6 @@ async function installFleet(cmd: Command, frameworkRoot: string): Promise<void>
const executableToolPaths = [
startAgentSessionPath,
startInteractionServicePath,
startTmuxHolderPath,
printInteractionPolicyPath,
sendMessagePath,
agentSendPath,
@@ -2363,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,
@@ -2390,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).`);
@@ -2667,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) {
@@ -2768,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).

View File

@@ -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/);
});
});

View File

@@ -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;
}

View File

@@ -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');
});
});

View File

@@ -1,509 +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 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,
};
}
/** 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);
}

View File

@@ -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('');

View File

@@ -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');

View File

@@ -1,15 +1,4 @@
import YAML from 'yaml';
import {
authorityForCanonicalClass,
canonicalizeRoleClass,
defaultOverrideDir,
defaultRolesDir,
extractClassesFromDir,
resolvePersonaFrom,
type PersonaDirs,
type PersonaResolution,
type RoleAuthority,
} from '../commands/fleet-personas.js';
export const ROSTER_V2_SUPPORTED_RUNTIMES = ['claude', 'codex', 'opencode', 'pi'] as const;
export const ROSTER_V2_REASONING_LEVELS = ['low', 'medium', 'high'] as const;
@@ -69,80 +58,6 @@ export interface FleetRosterV2 {
readonly agents: readonly FleetRosterV2Agent[];
}
export interface SemanticallyValidatedRosterV2Agent extends FleetRosterV2Agent {
readonly requestedClass: string;
readonly canonicalClass: string;
readonly canonicalToolPolicy: string;
readonly persona: PersonaResolution;
readonly authority: RoleAuthority;
}
export interface SemanticallyValidatedRosterV2 extends Omit<FleetRosterV2, 'agents'> {
readonly agents: readonly SemanticallyValidatedRosterV2Agent[];
}
const PROTECTED_TOOL_POLICY_CLASSES = new Set([
'merge-gate',
'validator',
'orchestrator',
'team-leader',
'interaction',
]);
/**
* Validate filesystem-backed roster semantics after synchronous structural parsing.
* Directory scans are batched once and every class must resolve to readable content.
*/
export async function validateRosterV2Semantics(
roster: FleetRosterV2,
opts: PersonaDirs = {},
): Promise<SemanticallyValidatedRosterV2> {
const rolesDir = opts.rolesDir ?? defaultRolesDir(opts.mosaicHome);
const overrideDir = opts.overrideDir ?? defaultOverrideDir(opts.mosaicHome);
const [base, over] = await Promise.all([
extractClassesFromDir(rolesDir),
extractClassesFromDir(overrideDir),
]);
const agents: SemanticallyValidatedRosterV2Agent[] = [];
for (const agent of roster.agents) {
const requestedClass = agent.className;
const { canonicalClass } = canonicalizeRoleClass(requestedClass);
const canonicalToolPolicy = canonicalizeRoleClass(agent.toolPolicy).canonicalClass;
const persona = await resolvePersonaFrom(requestedClass, {
rolesDir,
overrideDir,
base,
over,
});
if (!persona || persona.content.trim() === '') {
throw new RosterV2ValidationError(
`Roster v2 agent "${agent.name}" class "${requestedClass}" does not resolve to a readable persona.`,
);
}
if (
(PROTECTED_TOOL_POLICY_CLASSES.has(canonicalClass) ||
PROTECTED_TOOL_POLICY_CLASSES.has(canonicalToolPolicy)) &&
canonicalToolPolicy !== canonicalClass
) {
throw new RosterV2ValidationError(
`Roster v2 agent "${agent.name}" protected class "${canonicalClass}" tool policy must match its canonical class; received "${agent.toolPolicy}".`,
);
}
agents.push(
Object.freeze({
...agent,
requestedClass,
canonicalClass,
canonicalToolPolicy,
persona,
authority: authorityForCanonicalClass(canonicalClass),
}),
);
}
return Object.freeze({ ...roster, agents: Object.freeze(agents) });
}
export class RosterV2ValidationError extends Error {
constructor(message: string) {
super(message);

View File

@@ -1,59 +0,0 @@
import { describe, expect, it } from 'vitest';
import {
normalizeConnectorId,
normalizeConnectorScopes,
normalizeLeaseEpoch,
normalizeLogicalAgentIdentity,
normalizeLogicalBindingId,
type ConnectorExecutionContext,
type LogicalAgentIdentity,
} from './connector-lease.dto.js';
describe('logical agent connector lease contract', (): void => {
it('normalizes a runtime-neutral logical identity and binding vocabulary', (): void => {
const identity = normalizeLogicalAgentIdentity({
tenantId: ' tenant-01 ',
logicalAgentId: ' MOS.Primary ',
});
expect(identity).toEqual({ tenantId: 'tenant-01', logicalAgentId: 'mos.primary' });
expect(normalizeLogicalBindingId(' Discord:Operations ')).toBe('discord:operations');
expect(normalizeConnectorId(' PI.Worker-01 ')).toBe('pi.worker-01');
expect(Object.isFrozen(identity)).toBe(true);
expect(Object.keys(identity).sort()).toEqual(['logicalAgentId', 'tenantId']);
});
it('canonicalizes scopes and decimal fencing epochs', (): void => {
expect(normalizeConnectorScopes([' Runtime.Send ', 'tool.execute', 'runtime.send'])).toEqual([
'runtime.send',
'tool.execute',
]);
expect(normalizeLeaseEpoch('00042')).toBe('42');
});
it.each([
['', 'mos'],
['tenant', ''],
['tenant', 'claude session/123'],
])('rejects ambiguous identity values tenant=%j agent=%j', (tenantId, logicalAgentId): void => {
expect(() => normalizeLogicalAgentIdentity({ tenantId, logicalAgentId })).toThrow();
});
it('defines an adapter context without harness-native identity fields', (): void => {
const identity: LogicalAgentIdentity = { tenantId: 'tenant-01', logicalAgentId: 'mos' };
const context: ConnectorExecutionContext = {
identity,
bindingId: 'operator-chat',
connectorId: 'connector-a',
leaseId: '00000000-0000-4000-8000-000000000001',
leaseEpoch: '7',
scopes: ['runtime.send'],
correlationId: 'correlation-1',
grantExpiresAt: '2026-07-14T18:00:00.000Z',
};
expect(context).not.toHaveProperty('sessionId');
expect(context).not.toHaveProperty('tmuxSession');
expect(context).not.toHaveProperty('providerSessionId');
});
});

View File

@@ -1,199 +0,0 @@
const ID_PATTERN = /^[a-z0-9][a-z0-9._:@-]{0,127}$/;
const TENANT_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:@-]{0,127}$/;
const SCOPE_PATTERN = /^[a-z][a-z0-9._:-]{0,127}$/;
/** Stable Mosaic identity. It intentionally contains no runtime/provider session identifier. */
export interface LogicalAgentIdentity {
readonly tenantId: string;
readonly logicalAgentId: string;
}
export interface LogicalAgentBinding {
readonly identity: LogicalAgentIdentity;
readonly bindingId: string;
}
export interface ConnectorLease extends LogicalAgentBinding {
readonly leaseId: string;
readonly connectorId: string;
readonly scopes: readonly string[];
readonly leaseEpoch: string;
readonly acquiredAt: string;
readonly heartbeatAt: string;
readonly expiresAt: string;
readonly releasedAt?: string;
}
export interface AcquireConnectorLeaseInput {
readonly identity: LogicalAgentIdentity;
readonly bindingId: string;
readonly connectorId: string;
readonly scopes: readonly string[];
readonly ttlMs: number;
readonly correlationId: string;
}
export interface TakeoverConnectorLeaseInput extends AcquireConnectorLeaseInput {
readonly expectedEpoch: string;
}
export interface HeartbeatConnectorLeaseInput {
readonly lease: ConnectorLease;
readonly ttlMs: number;
readonly correlationId: string;
}
export interface ReleaseConnectorLeaseInput {
readonly lease: ConnectorLease;
readonly correlationId: string;
}
export interface IssueConnectorExecutionGrantInput {
readonly lease: ConnectorLease;
readonly scopes: readonly string[];
readonly ttlMs: number;
readonly correlationId: string;
}
/** Internal server grant. Object provenance is checked in addition to these fields. */
export interface ConnectorExecutionGrant extends LogicalAgentBinding {
readonly leaseId: string;
readonly connectorId: string;
readonly scopes: readonly string[];
readonly leaseEpoch: string;
readonly issuedAt: string;
readonly expiresAt: string;
readonly correlationId: string;
}
/** Normalized context passed to an adapter only after current-lease validation. */
export interface ConnectorExecutionContext extends LogicalAgentBinding {
readonly leaseId: string;
readonly connectorId: string;
readonly scopes: readonly string[];
readonly leaseEpoch: string;
readonly correlationId: string;
readonly grantExpiresAt: string;
}
export interface FencedConnectorAdapter<TInput, TOutput> {
execute(input: TInput, context: ConnectorExecutionContext): Promise<TOutput>;
}
export type ConnectorLeaseAuditEventType =
| 'acquire'
| 'renew'
| 'takeover'
| 'reject'
| 'release'
| 'expiry';
export type ConnectorLeaseAuditOutcome = 'succeeded' | 'denied';
export type ConnectorLeaseRejectReason =
| 'policy_denied'
| 'lease_held'
| 'takeover_required'
| 'cas_mismatch'
| 'lease_missing'
| 'lease_released'
| 'lease_expired'
| 'stale_epoch'
| 'connector_mismatch'
| 'scope_denied'
| 'forged_grant'
| 'grant_expired';
/** Credential-safe metadata only: no grant object, scope set, payload, token, or approval ref. */
export interface ConnectorLeaseAuditEvent extends LogicalAgentBinding {
readonly event: ConnectorLeaseAuditEventType;
readonly outcome: ConnectorLeaseAuditOutcome;
readonly connectorId: string;
readonly correlationId: string;
readonly occurredAt: string;
readonly leaseId?: string;
readonly leaseEpoch?: string;
readonly reason?: ConnectorLeaseRejectReason;
}
export interface ConnectorLeaseAcquireMutation extends AcquireConnectorLeaseInput {
readonly leaseId: string;
readonly now: string;
readonly expiresAt: string;
}
export interface ConnectorLeaseTakeoverMutation extends TakeoverConnectorLeaseInput {
readonly leaseId: string;
readonly now: string;
readonly expiresAt: string;
}
export interface ConnectorLeaseHeartbeatMutation extends HeartbeatConnectorLeaseInput {
readonly now: string;
readonly expiresAt: string;
}
export interface ConnectorLeaseReleaseMutation extends ReleaseConnectorLeaseInput {
readonly now: string;
}
export interface ConnectorLeaseStore {
acquire(input: ConnectorLeaseAcquireMutation): Promise<ConnectorLease>;
takeover(input: ConnectorLeaseTakeoverMutation): Promise<ConnectorLease>;
heartbeat(input: ConnectorLeaseHeartbeatMutation): Promise<ConnectorLease>;
release(input: ConnectorLeaseReleaseMutation): Promise<void>;
findCurrent(binding: LogicalAgentBinding): Promise<ConnectorLease | null>;
recordAudit(event: ConnectorLeaseAuditEvent): Promise<void>;
}
export function normalizeLogicalAgentIdentity(input: LogicalAgentIdentity): LogicalAgentIdentity {
const tenantId = requiredIdentifier(input.tenantId, 'tenant ID', TENANT_PATTERN, false);
const logicalAgentId = requiredIdentifier(
input.logicalAgentId,
'logical agent ID',
ID_PATTERN,
true,
);
return Object.freeze({ tenantId, logicalAgentId });
}
export function normalizeLogicalBindingId(value: string): string {
return requiredIdentifier(value, 'logical binding ID', ID_PATTERN, true);
}
export function normalizeConnectorId(value: string): string {
return requiredIdentifier(value, 'connector ID', ID_PATTERN, true);
}
export function normalizeCorrelationId(value: string): string {
return requiredIdentifier(value, 'correlation ID', TENANT_PATTERN, false);
}
export function normalizeConnectorScope(value: string): string {
return requiredIdentifier(value, 'connector scope', SCOPE_PATTERN, true);
}
export function normalizeConnectorScopes(values: readonly string[]): readonly string[] {
if (values.length === 0) throw new Error('At least one connector scope is required');
return Object.freeze(Array.from(new Set(values.map(normalizeConnectorScope))).sort());
}
export function normalizeLeaseEpoch(value: string): string {
const trimmed = value.trim();
if (!/^\d+$/.test(trimmed)) throw new Error('Lease epoch must be a positive decimal integer');
const epoch = BigInt(trimmed);
if (epoch < 1n) throw new Error('Lease epoch must be a positive decimal integer');
return epoch.toString(10);
}
function requiredIdentifier(
value: string,
label: string,
pattern: RegExp,
lowerCase: boolean,
): string {
const trimmed = value.trim();
const normalized = lowerCase ? trimmed.toLowerCase() : trimmed;
if (!pattern.test(normalized)) {
throw new Error(`${label} has an invalid normalized format`);
}
return normalized;
}

View File

@@ -4,4 +4,3 @@ export interface AgentSessionHandle {
}
export * from './agent-runtime-provider.js';
export * from './connector-lease.dto.js';