Compare commits

..

8 Commits

Author SHA1 Message Date
bc5e73629e feat(fleet): add generation-guarded agent CRUD (#773)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-15 12:03:05 +00:00
191efaefeb feat(fleet): enforce generated environment boundary (#772)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-15 08:40:32 +00:00
e9c4aa3e8b test(fleet): validate shipped artifact dispositions (#770)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-15 01:37:12 +00:00
a5e8e55401 feat(fleet): add shared role semantics (#768)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-15 00:53:47 +00:00
eb4e14ae5c feat(mos): add logical identity connector fencing (#757)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-07-14 23:33:06 +00:00
2e2280070a docs(#753): clear KBN-010 threat and schema gate (#765)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-14 21:46:44 +00:00
aa5b43bba2 feat(fleet): add roster v2 structural compiler (#764)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-14 20:37:52 +00:00
ba13c08890 Fixes #756 (#763)
Some checks failed
ci/woodpecker/push/ci-image Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was canceled
2026-07-14 20:26:15 +00:00
99 changed files with 17009 additions and 1326 deletions

View File

@@ -72,6 +72,7 @@ describe('interaction Discord/CLI durable-session integration', () => {
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([
{
instanceId: 'Nova',
agentConfigId: 'agent-config-nova',
guildId: 'guild-1',
channelId: 'channel-1',
pairedUsers: {
@@ -133,6 +134,7 @@ describe('interaction Discord/CLI durable-session integration', () => {
interactionBindings: [
{
instanceId: 'Nova',
agentConfigId: 'agent-config-nova',
guildId: 'guild-1',
channelId: 'channel-1',
pairedUsers: {

View File

@@ -115,6 +115,18 @@ describe('AgentService owner/tenant scope enforcement', () => {
).rejects.toBeInstanceOf(ForbiddenException);
await service.prompt(CONVERSATION_ID, 'owner prompt', OWNER_SCOPE);
expect(session.piSession.prompt).toHaveBeenCalledWith('owner prompt');
await service.prompt(CONVERSATION_ID, '', OWNER_SCOPE, [
{
id: 'attachment-001',
name: 'diagram.png',
url: 'https://cdn.example.test/diagram.png',
mimeType: 'image/png',
},
]);
expect(session.piSession.prompt).toHaveBeenLastCalledWith(
'\n\n[Untrusted channel attachments]\n' +
'{"id":"attachment-001","name":"diagram.png","mimeType":"image/png","url":"https://cdn.example.test/diagram.png"}',
);
await expect(service.destroySession(CONVERSATION_ID, FOREIGN_SCOPE)).rejects.toBeInstanceOf(
ForbiddenException,

View File

@@ -21,6 +21,12 @@ 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,
@@ -46,6 +52,13 @@ export function createGatewayRuntimeProviderRegistry(): AgentRuntimeProviderRegi
SkillLoaderService,
DurableSessionRepository,
DurableSessionService,
ConnectorLeaseRepository,
DenyConnectorLeasePolicy,
{
provide: CONNECTOR_LEASE_POLICY,
useExisting: DenyConnectorLeasePolicy,
},
ConnectorLeaseService,
{
provide: AGENT_RUNTIME_PROVIDER_REGISTRY,
useFactory: createGatewayRuntimeProviderRegistry,
@@ -78,6 +91,7 @@ export function createGatewayRuntimeProviderRegistry(): AgentRuntimeProviderRegi
SkillLoaderService,
DurableSessionService,
RuntimeProviderService,
ConnectorLeaseService,
AGENT_RUNTIME_PROVIDER_REGISTRY,
],
})

View File

@@ -15,6 +15,7 @@ import {
type ToolDefinition,
} from '@mariozechner/pi-coding-agent';
import type { Brain } from '@mosaicstack/brain';
import type { ChannelAttachmentDto } from '@mosaicstack/types';
import type { Memory, OperatorMemoryPlugin } from '@mosaicstack/memory';
import { BRAIN } from '../brain/brain.tokens.js';
import { MEMORY } from '../memory/memory.tokens.js';
@@ -43,6 +44,8 @@ export interface ConversationHistoryMessage {
role: 'user' | 'assistant' | 'system';
content: string;
createdAt: Date;
/** Validated, URI-referenced channel attachments preserved on session resume. */
attachments?: readonly ChannelAttachmentDto[];
}
export interface AgentSessionOptions {
@@ -428,7 +431,7 @@ export class AgentService implements OnModuleDestroy {
const formatMessage = (msg: ConversationHistoryMessage): string => {
const roleLabel =
msg.role === 'user' ? 'User' : msg.role === 'assistant' ? 'Assistant' : 'System';
return `**${roleLabel}:** ${msg.content}`;
return `**${roleLabel}:** ${msg.content}${this.attachmentContext(msg.attachments ?? [])}`;
};
const formatted = history.map((msg) => formatMessage(msg));
@@ -487,6 +490,21 @@ export class AgentService implements OnModuleDestroy {
return result;
}
private attachmentContext(attachments: readonly ChannelAttachmentDto[]): string {
if (attachments.length === 0) return '';
return `\n\n[Untrusted channel attachments]\n${attachments
.map((attachment: ChannelAttachmentDto): string =>
JSON.stringify({
id: attachment.id,
name: attachment.name,
mimeType: attachment.mimeType,
url: attachment.url,
...(attachment.sizeBytes !== undefined ? { sizeBytes: attachment.sizeBytes } : {}),
}),
)
.join('\n')}`;
}
private resolveModel(options?: AgentSessionOptions) {
if (!options?.provider && !options?.modelId) {
return this.providerService.getDefaultModel() ?? null;
@@ -673,7 +691,19 @@ export class AgentService implements OnModuleDestroy {
session.channels.delete(channel);
}
async prompt(sessionId: string, message: string, scope: ActorTenantScope): Promise<void> {
async prompt(sessionId: string, message: string, scope: ActorTenantScope): Promise<void>;
async prompt(
sessionId: string,
message: string,
scope: ActorTenantScope,
attachments: readonly ChannelAttachmentDto[] | undefined,
): Promise<void>;
async prompt(
sessionId: string,
message: string,
scope: ActorTenantScope,
attachments: readonly ChannelAttachmentDto[] = [],
): Promise<void> {
const session = this.sessions.get(sessionId);
if (!session) {
throw new Error(`No agent session found: ${sessionId}`);
@@ -681,12 +711,16 @@ export class AgentService implements OnModuleDestroy {
this.assertSessionScope(session, scope);
session.promptCount += 1;
// Channel attachments are untrusted URI references. Preserve exact,
// authenticated metadata for the agent without treating it as authority.
const attachmentContext = this.attachmentContext(attachments);
// Prepend session-scoped system override if present (renew TTL on each turn)
let effectiveMessage = message;
let effectiveMessage = `${message}${attachmentContext}`;
if (this.systemOverride) {
const override = await this.systemOverride.get(sessionId, scope);
if (override) {
effectiveMessage = `[System Override]\n${override}\n\n${message}`;
effectiveMessage = `[System Override]\n${override}\n\n${effectiveMessage}`;
await this.systemOverride.renew(sessionId, scope);
this.logger.debug(`Applied system override for session ${sessionId}`);
}

View File

@@ -0,0 +1,341 @@
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

@@ -0,0 +1,76 @@
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

@@ -0,0 +1,149 @@
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

@@ -0,0 +1,354 @@
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

@@ -0,0 +1,285 @@
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

@@ -1,3 +1,4 @@
import type { ChannelAttachmentDto } from '@mosaicstack/types';
import { IsOptional, IsString, IsUUID, MaxLength } from 'class-validator';
export class ChatRequestDto {
@@ -32,4 +33,7 @@ export class ChatSocketMessageDto {
@IsOptional()
@IsUUID()
agentId?: string;
/** Validated channel attachment references; binary content is not embedded. */
attachments?: readonly ChannelAttachmentDto[];
}

View File

@@ -4,6 +4,10 @@ import { ChatGateway } from './chat.gateway.js';
const CONVERSATION_ID = 'conversation-1';
const CANARY = 'sk_canary12345678';
function clientConversationKey(clientId: string, conversationId: string): string {
return `${clientId}\u0000${conversationId}`;
}
type GatewayInternals = {
clientSessions: Map<string, unknown>;
relayEvent(client: unknown, conversationId: string, event: unknown): void;
@@ -40,6 +44,7 @@ describe('ChatGateway redaction boundary', (): void => {
emit: vi.fn(),
};
const session = {
clientId: client.id,
conversationId: CONVERSATION_ID,
cleanup: vi.fn(),
assistantText: '',
@@ -47,7 +52,7 @@ describe('ChatGateway redaction boundary', (): void => {
pendingToolCalls: new Map(),
scope: { userId: 'user-1', tenantId: 'tenant-1' },
};
gateway.clientSessions.set(client.id, session);
gateway.clientSessions.set(clientConversationKey(client.id, CONVERSATION_ID), session);
gateway.relayEvent(client, CONVERSATION_ID, {
type: 'message_update',
@@ -139,6 +144,51 @@ describe('ChatGateway redaction boundary', (): void => {
});
});
it('isolates concurrent conversation streams sharing one Discord socket', (): void => {
const { gateway } = buildGateway();
const client = {
connected: true,
id: 'discord-client',
data: { user: { id: 'user-1' } },
emit: vi.fn(),
};
const firstConversation = 'Nova:discord:thread-1';
const secondConversation = 'Nova:discord:thread-2';
const createSession = (conversationId: string) => ({
clientId: client.id,
conversationId,
cleanup: vi.fn(),
assistantText: '',
toolCalls: [],
pendingToolCalls: new Map(),
scope: { userId: 'user-1', tenantId: 'tenant-1' },
});
const firstSession = createSession(firstConversation);
const secondSession = createSession(secondConversation);
gateway.clientSessions.set(clientConversationKey(client.id, firstConversation), firstSession);
gateway.clientSessions.set(clientConversationKey(client.id, secondConversation), secondSession);
gateway.relayEvent(client, firstConversation, {
type: 'message_update',
assistantMessageEvent: { type: 'text_delta', delta: 'first response ' },
});
gateway.relayEvent(client, secondConversation, {
type: 'message_update',
assistantMessageEvent: { type: 'text_delta', delta: 'second response ' },
});
expect(firstSession.assistantText).toBe('first response ');
expect(secondSession.assistantText).toBe('second response ');
expect(client.emit).toHaveBeenCalledWith('agent:text', {
conversationId: firstConversation,
text: 'first response ',
});
expect(client.emit).toHaveBeenCalledWith('agent:text', {
conversationId: secondConversation,
text: 'second response ',
});
});
it('persists only redacted assistant content with classifications', (): void => {
const { gateway, brain } = buildGateway();
const client = {
@@ -147,7 +197,8 @@ describe('ChatGateway redaction boundary', (): void => {
data: { user: { id: 'user-1' } },
emit: vi.fn(),
};
gateway.clientSessions.set(client.id, {
gateway.clientSessions.set(clientConversationKey(client.id, CONVERSATION_ID), {
clientId: client.id,
conversationId: CONVERSATION_ID,
cleanup: vi.fn(),
assistantText: CANARY,

View File

@@ -17,6 +17,7 @@ import {
parseDiscordInteractionBindings,
resolveDiscordInteractionActorId,
resolveDiscordInteractionBinding,
type DiscordAttachment,
type DiscordIngressEnvelope,
type DiscordIngressPayload,
} from '@mosaicstack/discord-plugin';
@@ -30,6 +31,7 @@ import type {
SystemReloadPayload,
RoutingDecisionInfo,
AbortPayload,
ChannelAttachmentDto,
} from '@mosaicstack/types';
import { AgentService, type ConversationHistoryMessage } from '../agent/agent.service.js';
import {
@@ -56,6 +58,7 @@ import { DiscordReplayProtector } from '../plugin/discord-replay-protector.js';
/** Per-client state tracking streaming accumulation for persistence. */
interface ClientSession {
clientId: string;
conversationId: string;
cleanup: () => void;
/** Accumulated assistant response text for the current turn. */
@@ -76,6 +79,68 @@ interface ClientSession {
*/
const modelOverrides = new Map<string, string>();
const MAX_REDACTION_BUFFER_LENGTH = 8_192;
const MAX_CHANNEL_ATTACHMENTS = 10;
const MAX_ATTACHMENT_METADATA_BYTES = 16_384;
const MAX_ATTACHMENT_ID_LENGTH = 128;
const MAX_ATTACHMENT_NAME_LENGTH = 255;
const MAX_ATTACHMENT_URL_LENGTH = 2_048;
const MAX_ATTACHMENT_MIME_LENGTH = 255;
function isSafeAttachmentUrl(value: string): boolean {
if (value.length === 0 || value.length > MAX_ATTACHMENT_URL_LENGTH) return false;
try {
const url = new URL(value);
return (
url.protocol === 'https:' &&
!url.username &&
!url.password &&
!url.hash &&
url.search.length === 0
);
} catch {
return false;
}
}
function hasValidAttachmentBounds(value: {
id: string;
name: string;
url: string;
sizeBytes?: number;
}): boolean {
return (
value.id.length > 0 &&
value.id.length <= MAX_ATTACHMENT_ID_LENGTH &&
value.name.length > 0 &&
value.name.length <= MAX_ATTACHMENT_NAME_LENGTH &&
isSafeAttachmentUrl(value.url) &&
(value.sizeBytes === undefined || (Number.isFinite(value.sizeBytes) && value.sizeBytes >= 0))
);
}
function isDiscordAttachment(value: unknown): value is DiscordAttachment {
if (typeof value !== 'object' || value === null) return false;
const attachment = value as Partial<DiscordAttachment>;
return (
typeof attachment.id === 'string' &&
typeof attachment.name === 'string' &&
typeof attachment.url === 'string' &&
(attachment.contentType === null ||
(typeof attachment.contentType === 'string' &&
attachment.contentType.length <= MAX_ATTACHMENT_MIME_LENGTH)) &&
(attachment.sizeBytes === undefined || typeof attachment.sizeBytes === 'number') &&
hasValidAttachmentBounds(attachment as DiscordAttachment)
);
}
function hasValidAttachmentArray(value: unknown, guard: (attachment: unknown) => boolean): boolean {
return (
Array.isArray(value) &&
value.length <= MAX_CHANNEL_ATTACHMENTS &&
JSON.stringify(value).length <= MAX_ATTACHMENT_METADATA_BYTES &&
value.every(guard)
);
}
function isDiscordIngressEnvelope(value: unknown): value is DiscordIngressEnvelope {
if (typeof value !== 'object' || value === null) return false;
@@ -88,7 +153,8 @@ function isDiscordIngressEnvelope(value: unknown): value is DiscordIngressEnvelo
return false;
}
const payload = envelope.payload as Record<string, unknown>;
return [
return (
[
payload['correlationId'],
payload['messageId'],
payload['guildId'],
@@ -96,15 +162,40 @@ function isDiscordIngressEnvelope(value: unknown): value is DiscordIngressEnvelo
payload['userId'],
payload['conversationId'],
payload['content'],
].every((field: unknown): boolean => typeof field === 'string');
].every((field: unknown): boolean => typeof field === 'string') &&
(payload['threadId'] === undefined || typeof payload['threadId'] === 'string') &&
(payload['attachments'] === undefined ||
hasValidAttachmentArray(payload['attachments'], isDiscordAttachment))
);
}
function isChannelAttachment(value: unknown): value is ChannelAttachmentDto {
if (typeof value !== 'object' || value === null) return false;
const attachment = value as Partial<ChannelAttachmentDto>;
return (
typeof attachment.id === 'string' &&
typeof attachment.name === 'string' &&
typeof attachment.url === 'string' &&
(attachment.mimeType === null ||
(typeof attachment.mimeType === 'string' &&
attachment.mimeType.length <= MAX_ATTACHMENT_MIME_LENGTH)) &&
(attachment.sizeBytes === undefined || typeof attachment.sizeBytes === 'number') &&
hasValidAttachmentBounds(attachment as ChannelAttachmentDto)
);
}
function isChatSocketMessage(value: unknown): value is ChatSocketMessageDto {
if (typeof value !== 'object' || value === null) return false;
const payload = value as { content?: unknown; conversationId?: unknown };
const payload = value as {
content?: unknown;
conversationId?: unknown;
attachments?: unknown;
};
return (
typeof payload.content === 'string' &&
(payload.conversationId === undefined || typeof payload.conversationId === 'string')
(payload.conversationId === undefined || typeof payload.conversationId === 'string') &&
(payload.attachments === undefined ||
hasValidAttachmentArray(payload.attachments, isChannelAttachment))
);
}
@@ -174,20 +265,24 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
handleDisconnect(client: Socket): void {
this.logger.log(`Client disconnected: ${client.id}`);
const session = this.clientSessions.get(client.id);
if (session) {
for (const [key, session] of this.clientSessions) {
if (session.clientId !== client.id) continue;
session.cleanup();
this.agentService.removeChannel(
session.conversationId,
`websocket:${client.id}`,
session.scope,
);
this.clientSessions.delete(client.id);
this.clientSessions.delete(key);
this.textEgressBuffers.delete(key);
this.thinkingEgressBuffers.delete(key);
this.overflowedEgress.delete(`${key}:agent:text`);
this.overflowedEgress.delete(`${key}:agent:thinking`);
}
this.textEgressBuffers.delete(client.id);
this.thinkingEgressBuffers.delete(client.id);
this.overflowedEgress.delete(this.egressKey(client, 'agent:text'));
this.overflowedEgress.delete(this.egressKey(client, 'agent:thinking'));
}
private clientConversationKey(client: Pick<Socket, 'id'>, conversationId: string): string {
return `${client.id}\u0000${conversationId}`;
}
private getClientScope(client: Socket): ActorTenantScope | null {
@@ -218,7 +313,25 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
}
discordIngress = this.resolveDiscordIngress(client, rawData);
if (!discordIngress) return;
data = { conversationId: discordIngress.conversationId, content: discordIngress.content };
data = {
conversationId: discordIngress.conversationId,
content: discordIngress.content,
...(discordIngress.attachments
? {
attachments: discordIngress.attachments.map(
(attachment): ChannelAttachmentDto => ({
id: attachment.id,
name: attachment.name,
url: attachment.url,
mimeType: attachment.contentType,
...(attachment.sizeBytes !== undefined
? { sizeBytes: attachment.sizeBytes }
: {}),
}),
),
}
: {}),
};
} else {
if (!isChatSocketMessage(rawData)) {
this.logger.warn(`Rejected malformed chat message from ${client.id}`);
@@ -227,6 +340,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
data = rawData;
}
const conversationId = data.conversationId ?? uuid();
const clientConversationKey = this.clientConversationKey(client, conversationId);
const discordServiceUserId = process.env['DISCORD_SERVICE_USER_ID'];
if (discordIngress && !discordServiceUserId) {
this.logger.warn(
@@ -281,7 +395,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
this.logger.log(
`Using /model override "${modelOverride}" for conversation=${conversationId}`,
);
} else if (!resolvedProvider && !resolvedModelId) {
} else if (!resolvedProvider && !resolvedModelId && !discordIngress) {
// No explicit provider/model from client — use routing engine (M4-012)
try {
const routingDecision = await this.routingEngine.resolve(data.content, userId);
@@ -304,12 +418,24 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
}
}
let resolvedAgentConfigId = data.agentId;
if (discordIngress) {
const binding = this.discordBindingFor(discordIngress, 'send');
const agentConfig = binding
? await this.brain.agents.findById(binding.agentConfigId)
: undefined;
if (!binding || !agentConfig || agentConfig.name !== binding.instanceId) {
throw new Error('Configured Discord logical agent is not provisioned');
}
resolvedAgentConfigId = agentConfig.id;
}
// M5-004: Use existingSessionId as sessionId when available (session reuse)
const sessionIdToCreate = existingSessionId ?? conversationId;
agentSession = await this.agentService.createSession(sessionIdToCreate, {
provider: resolvedProvider,
modelId: resolvedModelId,
agentConfigId: data.agentId,
agentConfigId: resolvedAgentConfigId,
userId,
tenantId: scope.tenantId,
conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined,
@@ -360,6 +486,17 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
discordUserId: discordIngress?.userId,
}
: {}),
...(data.attachments && data.attachments.length > 0
? {
channelAttachments: data.attachments.map(
(attachment): ChannelAttachmentDto => ({
...attachment,
name: redactSensitiveContent(attachment.name).content,
url: redactSensitiveContent(attachment.url).content,
}),
),
}
: {}),
classifications: redactSensitiveContent(data.content).classifications,
},
},
@@ -374,7 +511,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
}
// Always clean up previous listener to prevent leak
const existing = this.clientSessions.get(client.id);
const existing = this.clientSessions.get(clientConversationKey);
if (existing) {
existing.cleanup();
}
@@ -389,10 +526,11 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
);
// Preserve routing decision from the existing client session if we didn't get a new one
const prevClientSession = this.clientSessions.get(client.id);
const prevClientSession = this.clientSessions.get(clientConversationKey);
const routingDecisionToStore = sessionRoutingDecision ?? prevClientSession?.lastRoutingDecision;
this.clientSessions.set(client.id, {
this.clientSessions.set(clientConversationKey, {
clientId: client.id,
conversationId,
cleanup,
assistantText: '',
@@ -438,7 +576,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
// Dispatch to agent
try {
await this.agentService.prompt(conversationId, data.content, scope);
await this.agentService.prompt(conversationId, data.content, scope, data.attachments);
} catch (err) {
this.logger.error(
`Agent prompt failed for client=${client.id}, conversation=${conversationId}`,
@@ -645,9 +783,9 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
};
// Emit to all clients currently subscribed to this conversation
for (const [clientId, session] of this.clientSessions) {
for (const session of this.clientSessions.values()) {
if (session.conversationId === conversationId && this.scopesEqual(session.scope, scope)) {
const socket = this.server.sockets.sockets.get(clientId);
const socket = this.server.sockets.sockets.get(session.clientId);
if (socket?.connected) {
socket.emit('session:info', payload);
}
@@ -677,16 +815,10 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
!this.durableSessions
)
return;
const binding = resolveDiscordInteractionBinding(
parseDiscordInteractionBindings(process.env['DISCORD_INTERACTION_BINDINGS']),
ingress.guildId,
ingress.channelId,
ingress.userId,
'approve',
);
const binding = this.discordBindingFor(ingress, 'approve');
const actorId = binding && resolveDiscordInteractionActorId(binding, ingress.userId);
const agentName = process.env['MOSAIC_AGENT_NAME']?.trim();
if (!actorId || !agentName || binding.instanceId !== agentName) {
const agentName = binding?.instanceId;
if (!actorId || !agentName) {
this.logger.warn(
`Rejected Discord approval without a matching runtime agent from ${client.id}`,
);
@@ -774,13 +906,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
const tenantId = process.env['DISCORD_SERVICE_TENANT_ID']?.trim();
if (!ingress || !approvalRef || !tenantId || !this.runtimeRegistry || !this.durableSessions)
return;
const binding = resolveDiscordInteractionBinding(
parseDiscordInteractionBindings(process.env['DISCORD_INTERACTION_BINDINGS']),
ingress.guildId,
ingress.channelId,
ingress.userId,
'stop',
);
const binding = this.discordBindingFor(ingress, 'stop');
const actorId = binding && resolveDiscordInteractionActorId(binding, ingress.userId);
if (!actorId) return;
@@ -854,17 +980,18 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
return null;
}
try {
const binding = resolveDiscordInteractionBinding(
parseDiscordInteractionBindings(process.env['DISCORD_INTERACTION_BINDINGS']),
payload.guildId,
payload.channelId,
payload.userId,
operation,
);
const binding = this.discordBindingFor(payload, operation);
if (!binding) {
this.logger.warn(`Rejected unpaired Discord ingress from ${client.id}`);
return null;
}
const expectedConversationId = `${binding.instanceId}:discord:${payload.threadId ?? payload.channelId}`;
if (payload.conversationId !== expectedConversationId) {
this.logger.warn(
`Rejected Discord ingress for a different logical agent from ${client.id}`,
);
return null;
}
} catch {
this.logger.warn(
`Rejected Discord ingress without valid binding configuration from ${client.id}`,
@@ -880,6 +1007,19 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
return payload;
}
private discordBindingFor(
payload: DiscordIngressPayload,
operation: 'send' | 'approve' | 'stop',
) {
return resolveDiscordInteractionBinding(
parseDiscordInteractionBindings(process.env['DISCORD_INTERACTION_BINDINGS']),
payload.guildId,
payload.channelId,
payload.userId,
operation,
);
}
private readDiscordAllowlist(name: string): string[] {
return (process.env[name] ?? '')
.split(',')
@@ -958,11 +1098,15 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
const messages = await this.brain.conversations.findMessages(conversationId, userId);
if (messages.length === 0) return [];
return messages.map((msg) => ({
return messages.map((msg) => {
const attachments = this.persistedChannelAttachments(msg.metadata);
return {
role: msg.role as 'user' | 'assistant' | 'system',
content: msg.content,
createdAt: msg.createdAt,
}));
...(attachments ? { attachments } : {}),
};
});
} catch (err) {
this.logger.error(
`Failed to load conversation history for conversation=${conversationId}`,
@@ -972,6 +1116,14 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
}
}
private persistedChannelAttachments(metadata: unknown): readonly ChannelAttachmentDto[] | null {
if (typeof metadata !== 'object' || metadata === null) return null;
const attachments = (metadata as { channelAttachments?: unknown }).channelAttachments;
return hasValidAttachmentArray(attachments, isChannelAttachment)
? (attachments as readonly ChannelAttachmentDto[])
: null;
}
private appendAndFlushRedactedEgress(
client: Socket,
conversationId: string,
@@ -979,18 +1131,19 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
buffers: Map<string, string>,
delta: string,
): void {
const key = this.egressKey(client, eventName);
const sessionKey = this.clientConversationKey(client, conversationId);
const key = this.egressKey(client, conversationId, eventName);
if (this.overflowedEgress.has(key)) return;
const buffered = `${buffers.get(client.id) ?? ''}${delta}`;
const buffered = `${buffers.get(sessionKey) ?? ''}${delta}`;
if (buffered.length > MAX_REDACTION_BUFFER_LENGTH) {
buffers.delete(client.id);
buffers.delete(sessionKey);
this.overflowedEgress.add(key);
client.emit(eventName, { conversationId, text: '[REDACTED_STREAM_OVERFLOW]' });
return;
}
buffers.set(client.id, buffered);
buffers.set(sessionKey, buffered);
this.flushRedactedEgress(client, conversationId, eventName, buffers, false);
}
@@ -1006,21 +1159,22 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
buffers: Map<string, string>,
final: boolean,
): void {
const key = this.egressKey(client, eventName);
const sessionKey = this.clientConversationKey(client, conversationId);
const key = this.egressKey(client, conversationId, eventName);
if (this.overflowedEgress.has(key)) {
if (final) this.overflowedEgress.delete(key);
return;
}
const buffered = buffers.get(client.id) ?? '';
const buffered = buffers.get(sessionKey) ?? '';
const releaseLength = final ? buffered.length : this.safeRedactionPrefixLength(buffered);
const released = buffered.slice(0, releaseLength);
const pending = buffered.slice(releaseLength);
if (pending) {
buffers.set(client.id, pending);
buffers.set(sessionKey, pending);
} else {
buffers.delete(client.id);
buffers.delete(sessionKey);
}
if (released) {
@@ -1089,8 +1243,12 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
return retainedFrom;
}
private egressKey(client: Socket, eventName: 'agent:text' | 'agent:thinking'): string {
return `${client.id}:${eventName}`;
private egressKey(
client: Socket,
conversationId: string,
eventName: 'agent:text' | 'agent:thinking',
): string {
return `${this.clientConversationKey(client, conversationId)}:${eventName}`;
}
private relayEvent(client: Socket, conversationId: string, event: AgentSessionEvent): void {
@@ -1101,26 +1259,27 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
return;
}
const sessionKey = this.clientConversationKey(client, conversationId);
switch (event.type) {
case 'agent_start': {
// Reset accumulation buffers for the new turn
const cs = this.clientSessions.get(client.id);
const cs = this.clientSessions.get(sessionKey);
if (cs) {
cs.assistantText = '';
cs.toolCalls = [];
cs.pendingToolCalls.clear();
}
this.textEgressBuffers.set(client.id, '');
this.thinkingEgressBuffers.set(client.id, '');
this.overflowedEgress.delete(this.egressKey(client, 'agent:text'));
this.overflowedEgress.delete(this.egressKey(client, 'agent:thinking'));
this.textEgressBuffers.set(sessionKey, '');
this.thinkingEgressBuffers.set(sessionKey, '');
this.overflowedEgress.delete(this.egressKey(client, conversationId, 'agent:text'));
this.overflowedEgress.delete(this.egressKey(client, conversationId, 'agent:thinking'));
client.emit('agent:start', { conversationId });
break;
}
case 'agent_end': {
// Gather usage stats from the Pi session
const activeClientSession = this.clientSessions.get(client.id);
const activeClientSession = this.clientSessions.get(sessionKey);
const agentSession = activeClientSession
? this.agentService.getSession(conversationId, activeClientSession.scope)
: undefined;
@@ -1173,7 +1332,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
}
// Persist the assistant message with metadata
const cs = this.clientSessions.get(client.id);
const cs = this.clientSessions.get(sessionKey);
const userId = (client.data.user as { id: string } | undefined)?.id;
if (cs && userId && cs.assistantText.trim().length > 0) {
const metadata: Record<string, unknown> = {
@@ -1225,7 +1384,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
const assistantEvent = event.assistantMessageEvent;
if (assistantEvent.type === 'text_delta') {
// Keep raw stream material in memory only; persist and emit only redacted text.
const cs = this.clientSessions.get(client.id);
const cs = this.clientSessions.get(sessionKey);
if (cs) {
cs.assistantText += assistantEvent.delta;
}
@@ -1250,7 +1409,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
case 'tool_execution_start': {
// Track pending tool call for later recording
const cs = this.clientSessions.get(client.id);
const cs = this.clientSessions.get(sessionKey);
if (cs) {
cs.pendingToolCalls.set(event.toolCallId, {
toolName: event.toolName,
@@ -1267,7 +1426,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
case 'tool_execution_end': {
// Finalise tool call record
const cs = this.clientSessions.get(client.id);
const cs = this.clientSessions.get(sessionKey);
if (cs) {
const pending = cs.pendingToolCalls.get(event.toolCallId);
cs.toolCalls.push({

View File

@@ -24,6 +24,7 @@ const ENV_KEYS = [
'DISCORD_ALLOWED_CHANNEL_IDS',
'DISCORD_ALLOWED_USER_IDS',
'MOSAIC_AGENT_NAME',
'MOSAIC_AGENT_CONFIG_ID',
] as const;
const savedEnv = new Map<string, string | undefined>();
@@ -33,12 +34,14 @@ function configureDiscordEnv(role: 'admin' | 'member' = 'admin'): void {
process.env['DISCORD_SERVICE_USER_ID'] = 'discord-service';
process.env['DISCORD_SERVICE_TENANT_ID'] = 'tenant-discord';
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
process.env['MOSAIC_AGENT_CONFIG_ID'] = 'agent-config-nova';
process.env['DISCORD_ALLOWED_GUILD_IDS'] = 'guild-001';
process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001';
process.env['DISCORD_ALLOWED_USER_IDS'] = 'user-001';
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([
{
instanceId: 'Nova',
agentConfigId: 'agent-config-nova',
guildId: 'guild-001',
channelId: 'channel-001',
pairedUsers: {
@@ -141,7 +144,7 @@ function createPayload(overrides: Partial<DiscordIngressPayload> = {}): DiscordI
guildId: 'guild-001',
channelId: 'channel-001',
userId: 'user-001',
conversationId: 'discord-channel-001',
conversationId: 'Nova:discord:channel-001',
content: 'hello Tess',
...overrides,
};
@@ -153,6 +156,7 @@ describe('Discord ingress security', () => {
JSON.stringify([
{
instanceId: 'Nova',
agentConfigId: 'agent-config-nova',
guildId: 'guild-001',
channelId: 'channel-001',
pairedUsers: { 'user-001': 'admin' },
@@ -170,6 +174,7 @@ describe('Discord ingress security', () => {
[
{
instanceId: 'Nova',
agentConfigId: 'agent-config-nova',
guildId: 'guild-001',
channelId: 'channel-001',
pairedUsers: { 'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' } },
@@ -307,27 +312,12 @@ describe('Discord ingress security', () => {
);
});
it.each([
[
'binding',
() => {
process.env['MOSAIC_AGENT_NAME'] = 'Other';
},
],
[
'durable session',
(durable: { getSnapshot: ReturnType<typeof vi.fn> }) => {
it('rejects approval when the durable session targets a different logical agent', async () => {
configureDiscordEnv();
const { gateway, client, durable } = discordGateway('admin');
durable.getSnapshot.mockResolvedValueOnce({
identity: { agentName: 'Other', providerId: 'fleet', runtimeSessionId: 'runtime-1' },
});
},
],
])(
'rejects approval when the %s targets a different runtime agent',
async (_source, configure) => {
configureDiscordEnv();
const { gateway, client, durable } = discordGateway('admin');
configure(durable);
await gateway.handleDiscordApproval(
client as never,
@@ -340,8 +330,28 @@ describe('Discord ingress security', () => {
approvalId: undefined,
expiresAt: undefined,
});
},
});
it('rejects privileged envelopes with a forged current conversation route', async () => {
configureDiscordEnv();
const { gateway, client } = discordGateway('admin');
await gateway.handleDiscordApproval(
client as never,
ingressEnvelope('/approve', 'forged-approval-route', {
conversationId: 'Nova:discord:other-channel',
}),
);
await gateway.handleDiscordStop(
client as never,
ingressEnvelope('/stop forged', 'forged-stop-route', {
conversationId: 'Nova:discord:other-channel',
}),
);
expect(client.emit).not.toHaveBeenCalledWith('discord:approval', expect.anything());
expect(client.emit).not.toHaveBeenCalledWith('discord:stop', expect.anything());
});
it('rejects unpaired and non-admin Discord users for approval and stop', async () => {
configureDiscordEnv();
@@ -398,6 +408,267 @@ describe('Discord ingress security', () => {
]);
});
it.each([
'https://user:password@cdn.example.test/diagram.png',
'https://cdn.example.test/diagram.png?token=secret',
'https://cdn.example.test/diagram.png?X-Amz-Signature=secret',
'https://cdn.example.test/diagram.png?auth=secret',
'https://cdn.example.test/diagram.png?hm=secret',
])('rejects credential-bearing attachment URLs before gateway dispatch', async (url) => {
configureDiscordEnv();
const { gateway, client } = discordGateway('admin');
await gateway.handleMessage(
client as never,
ingressEnvelope('', `credential-url-${url.length}`, {
conversationId: 'Nova:discord:channel-001',
attachments: [
{ id: 'attachment-credential', name: 'diagram.png', url, contentType: 'image/png' },
],
}),
);
expect(client.emit).not.toHaveBeenCalledWith('message:ack', expect.anything());
});
it("selects each binding's trusted logical-agent config when creating Discord sessions", async () => {
configureDiscordEnv();
process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001,channel-002';
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([
{
instanceId: 'Nova',
agentConfigId: 'agent-config-nova',
guildId: 'guild-001',
channelId: 'channel-001',
pairedUsers: {
'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' },
},
},
{
instanceId: 'Orion',
agentConfigId: 'agent-config-orion',
guildId: 'guild-001',
channelId: 'channel-002',
pairedUsers: {
'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' },
},
},
]);
const session = {
provider: 'configured-provider',
modelId: 'configured-model',
piSession: {
thinkingLevel: 'medium',
getAvailableThinkingLevels: (): string[] => ['medium'],
},
};
const createSession = vi.fn().mockResolvedValue(session);
const agentService = {
getSession: vi.fn().mockReturnValue(undefined),
createSession,
recordMessage: vi.fn(),
onEvent: vi.fn().mockReturnValue((): void => undefined),
addChannel: vi.fn(),
prompt: vi.fn().mockResolvedValue(undefined),
};
const brain = {
agents: {
findById: vi.fn((id: string) =>
Promise.resolve({
id,
name: id === 'agent-config-orion' ? 'Orion' : 'Nova',
}),
),
},
conversations: {
findById: vi.fn().mockResolvedValue({ id: 'Nova:discord:channel-001' }),
findMessages: vi.fn().mockResolvedValue([]),
create: vi.fn().mockResolvedValue(undefined),
update: vi.fn().mockResolvedValue(undefined),
addMessage: vi.fn().mockResolvedValue(undefined),
},
};
const routingEngine = { resolve: vi.fn() };
const gateway = new ChatGateway(
agentService as never,
{} as never,
brain as never,
{} as never,
{} as never,
routingEngine as never,
);
const client = {
id: 'discord-client-new-session',
data: { discordService: true },
emit: vi.fn(),
};
await gateway.handleMessage(
client as never,
ingressEnvelope('start configured session', 'configured-session-001', {
conversationId: 'Nova:discord:channel-001',
}),
);
await gateway.handleMessage(
client as never,
ingressEnvelope('start second configured session', 'configured-session-002', {
channelId: 'channel-002',
conversationId: 'Orion:discord:channel-002',
}),
);
expect(createSession).toHaveBeenCalledWith(
'Nova:discord:channel-001',
expect.objectContaining({
agentConfigId: 'agent-config-nova',
userId: 'discord-service',
tenantId: 'tenant-discord',
}),
);
expect(createSession).toHaveBeenCalledWith(
'Orion:discord:channel-002',
expect.objectContaining({ agentConfigId: 'agent-config-orion' }),
);
expect(routingEngine.resolve).not.toHaveBeenCalled();
});
it('retains validated persisted attachments in resumed conversation history', async () => {
const attachment = {
id: 'attachment-history',
name: 'diagram.png',
url: 'https://cdn.example.test/diagram.png',
mimeType: 'image/png',
sizeBytes: 4_096,
};
const gateway = new ChatGateway(
{} as never,
{} as never,
{
conversations: {
findMessages: vi.fn().mockResolvedValue([
{
role: 'user',
content: '',
createdAt: new Date('2026-07-14T12:00:00.000Z'),
metadata: { channelAttachments: [attachment] },
},
]),
},
} as never,
{} as never,
{} as never,
{} as never,
) as unknown as {
loadConversationHistory(
conversationId: string,
userId: string,
): Promise<Array<{ attachments?: readonly (typeof attachment)[] }>>;
};
await expect(
gateway.loadConversationHistory('Nova:discord:channel-001', 'discord-service'),
).resolves.toEqual([expect.objectContaining({ attachments: [attachment] })]);
});
it('rejects malformed signed attachment payloads before gateway dispatch', async () => {
configureDiscordEnv();
const { gateway, client } = discordGateway('admin');
const malformedPayload: Record<string, unknown> = {
...createPayload({
messageId: 'malformed-attachments-001',
conversationId: 'Nova:discord:channel-001',
}),
attachments: { id: 'not-an-array' },
};
const envelope = createDiscordIngressEnvelope(
malformedPayload as unknown as DiscordIngressPayload,
SERVICE_TOKEN,
);
await gateway.handleMessage(client as never, envelope);
expect(client.emit).not.toHaveBeenCalledWith('message:ack', expect.anything());
});
it('preserves authenticated attachment metadata through persistence and agent dispatch', async () => {
configureDiscordEnv();
const prompt = vi.fn().mockResolvedValue(undefined);
const addMessage = vi.fn().mockResolvedValue(undefined);
const session = {
provider: 'test-provider',
modelId: 'test-model',
piSession: {
thinkingLevel: 'medium',
getAvailableThinkingLevels: (): string[] => ['medium'],
},
};
const agentService = {
getSession: vi.fn().mockReturnValue(session),
recordMessage: vi.fn(),
onEvent: vi.fn().mockReturnValue((): void => undefined),
addChannel: vi.fn(),
prompt,
};
const brain = {
conversations: {
findById: vi.fn().mockResolvedValue({ id: 'Nova:discord:channel-001' }),
create: vi.fn().mockResolvedValue(undefined),
update: vi.fn().mockResolvedValue(undefined),
addMessage,
},
};
const gateway = new ChatGateway(
agentService as never,
{} as never,
brain as never,
{} as never,
{} as never,
{} as never,
);
const client = {
id: 'discord-client-001',
data: { discordService: true },
emit: vi.fn(),
};
const attachment = {
id: 'attachment-001',
name: 'diagram.png',
url: 'https://cdn.example.test/diagram.png',
contentType: 'image/png',
sizeBytes: 4_096,
};
await gateway.handleMessage(
client as never,
ingressEnvelope('', 'attachment-message-001', {
conversationId: 'Nova:discord:channel-001',
attachments: [attachment],
}),
);
const expectedAttachment = {
id: attachment.id,
name: attachment.name,
url: attachment.url,
mimeType: attachment.contentType,
sizeBytes: attachment.sizeBytes,
};
expect(prompt).toHaveBeenCalledWith(
'Nova:discord:channel-001',
'',
{ userId: 'discord-service', tenantId: 'tenant-discord' },
[expectedAttachment],
);
expect(addMessage).toHaveBeenCalledWith(
expect.objectContaining({
conversationId: 'Nova:discord:channel-001',
metadata: expect.objectContaining({ channelAttachments: [expectedAttachment] }),
}),
'discord-service',
);
});
it('accepts a thread message through its allowed bound parent channel', () => {
const emitted = vi.fn();
const plugin = new DiscordPlugin({
@@ -410,6 +681,7 @@ describe('Discord ingress security', () => {
interactionBindings: [
{
instanceId: 'Nova',
agentConfigId: 'agent-config-nova',
guildId: 'guild-001',
channelId: 'channel-001',
pairedUsers: { 'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' } },

View File

@@ -61,6 +61,16 @@ function requiredDiscordAllowlist(name: string): string[] {
return value;
}
function optionalPositiveInteger(name: string): number | undefined {
const raw = process.env[name];
if (raw === undefined) return undefined;
const value = Number(raw);
if (!Number.isInteger(value) || value <= 0) {
throw new Error(`${name} must be a positive integer when configured`);
}
return value;
}
function createPluginRegistry(): IChannelPlugin[] {
const plugins: IChannelPlugin[] = [];
const discordToken = process.env['DISCORD_BOT_TOKEN'];
@@ -82,6 +92,10 @@ function createPluginRegistry(): IChannelPlugin[] {
guildId: discordGuildId,
gatewayUrl: discordGatewayUrl,
serviceToken: discordServiceToken,
messageRateLimitPerMinute: optionalPositiveInteger(
'DISCORD_MESSAGE_RATE_LIMIT_PER_MINUTE',
),
threadRateLimitPerMinute: optionalPositiveInteger('DISCORD_THREAD_RATE_LIMIT_PER_MINUTE'),
allowedGuildIds: requiredDiscordAllowlist('DISCORD_ALLOWED_GUILD_IDS'),
allowedChannelIds: requiredDiscordAllowlist('DISCORD_ALLOWED_CHANNEL_IDS'),
allowedUserIds: requiredDiscordAllowlist('DISCORD_ALLOWED_USER_IDS'),

View File

@@ -221,6 +221,100 @@ Delivery uses five gated milestones: runtime contracts/security; Pi service/stat
---
## Official Channel Plugin Workstream (#756)
### Problem and Objective
The Discord plugin currently couples Discord event handling, gateway bridging, and reply routing in one implementation and activates only on mentions. Mosaic needs an official channel adapter that behaves the same no matter whether the bound logical agent currently runs through Claude, Codex, Pi, OpenCode, or a future harness. The Discord connection and conversation address must remain stable while the gateway changes the runtime provider behind that logical session.
The objective is to make Discord the first implementation of a transport-neutral official channel contract, with explicit authorization and deterministic channel/thread routing that future Matrix, Slack, and other adapters can share.
### Scope
#### In Scope
1. `CHN-001`: Transport-neutral channel adapter, route, message, attachment, authorization-principal, response-target, and health contracts in `@mosaicstack/types`, including trusted per-binding logical-agent configuration selection.
2. `CHN-002`: Stable channel conversation addresses based on logical agent plus channel/thread identity; harness, model, and runtime-provider IDs are forbidden from channel session keys.
3. `DSC-001`: An authorized untagged message in a configured agent-bound channel routes to the agent and receives its response in that channel.
4. `DSC-002`: A bot mention in a configured parent channel creates a Discord thread, or reuses the thread already attached to that same native message; the mentioned turn and subsequent thread turns route and respond in that thread.
5. `DSC-003`: A message already inside an authorized thread inherits authorization from its configured parent and never attempts a nested thread.
6. `DSC-004`: Guild, parent channel, user, pairing, and role authorization remains default-deny before thread creation or gateway dispatch.
7. `DSC-005`: Discord service authentication, HMAC envelope integrity, replay protection, attachments, approvals, response chunking, and correlation behavior remain intact.
8. `DSC-006`: The Discord adapter exposes lifecycle and health behavior through the shared channel contract without importing a harness SDK.
#### Out of Scope
1. The logical-agent lease, fencing epoch, execution grant, checkpoint, or cross-harness takeover implementation tracked by #754/#755.
2. Dynamic Discord authorization administration in the web UI.
3. Multi-guild tenant isolation, DMs, slash commands, voice, reactions, or production bot deployment.
4. Implementing Matrix or Slack adapters in this slice.
### Non-Functional Requirements
1. **Security:** no thread or dispatch side effect occurs until guild, parent channel, user, pairing, role, and bounded per-user/channel rate checks pass; attachment metadata is shape- and size-bounded; credentials never enter source, messages, session keys, or logs.
2. **Portability:** channel contracts and stable conversation IDs contain no Claude, Codex, Pi, OpenCode, model, process, or provider-specific field; each configuration-owned binding selects its trusted logical agent without changing the channel identity.
3. **Reliability:** repeated messages for one channel/thread resolve the same conversation handle; reconnecting the adapter does not require a harness-specific rebinding.
4. **Maintainability:** Discord-specific API translation stays in the Discord package; gateway and future adapters depend on transport-neutral contracts.
5. **Observability:** thread creation or routing failure is reported without message content or credential material.
### Acceptance Criteria
1. `AC-CHN-01`: Contract and behavior tests prove the plugin route contains only logical agent plus channel/thread identity and produces the same stable conversation handle regardless of underlying harness selection.
2. `AC-CHN-02`: A mentioned authorized parent-channel message creates a thread (or reuses its already-attached thread), dispatches to the thread conversation, and targets the response to that thread.
3. `AC-CHN-03`: An untagged authorized parent-channel message dispatches to the parent conversation and targets the response to the parent channel.
4. `AC-CHN-04`: Untagged follow-ups inside an authorized thread dispatch and respond in that same thread without creating a nested thread.
5. `AC-CHN-05`: Unauthorized guilds, channels, users, unpaired users, insufficient roles, and rate-limited senders produce no thread and no gateway dispatch.
6. `AC-CHN-06`: Shared channel contracts are exported from `@mosaicstack/types`, Discord implements the lifecycle/health seam, and no harness SDK is imported by the plugin.
7. `AC-CHN-07`: Focused routing/auth tests, package tests, typecheck, lint, formatting, coverage, independent code/security review, and terminal-green CI pass.
### Constraints, Risks, and Assumptions
- Dependency: Mosaic gateway remains the policy, durable-session, audit, and runtime-provider boundary.
- Constraint: This work must not modify orchestrator-to-Pi migration or #754/#755 lease/fencing files.
- Risk: accepting untagged messages could create noisy or unintended agent input. Mitigation: only explicitly configured channels and paired, role-authorized users are accepted, with bounded per-user/channel message and thread rates.
- Risk: Discord thread creation can fail because of channel permissions, archived state, or API rate limits. Mitigation: fail without dispatching a turn whose response destination cannot be honored, and emit sanitized diagnostics.
- `ASSUMPTION:` Configured channels are dedicated agent interaction surfaces, so authorized untagged human messages are intentional agent input.
- `ASSUMPTION:` Mention in a parent channel selects a public thread; messages already in a thread remain there because Discord has no nested threads.
- `ASSUMPTION:` One Discord bot may serve multiple configuration-owned logical-agent bindings.
- `ASSUMPTION:` Static allowlists and paired-user roles are the authorization administration surface for this slice.
### Testing and Delivery Intent
Use TDD for remote-ingress routing and permission boundaries. Required evidence includes parent-channel mention, untagged parent message, existing-thread follow-up, existing-thread mention, thread reuse, unauthorized side-effect denial, stable harness-neutral conversation identity, adapter health, and regression coverage for signed envelopes and approvals. Deliver through issue #756, a reviewed squash PR to `main`, terminal-green CI, and issue closure.
---
## 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
@@ -575,7 +669,8 @@ Discord remote control channel. Architecture inspired by OpenClaw (https://githu
- Single-guild binding only (v0.1.0) — prevents data leaks between servers
- Receives Discord messages, dispatches through gateway routing
- Streams agent responses back to Discord (chunked for 2000-char limit)
- Supports mention-based activation, thread management for multi-turn
- Routes authorized untagged messages in-channel; mentions create threads (or reuse the same message's attached thread) for multi-turn topics
- Uses stable logical-agent/channel conversation addresses independent of the active harness/provider
- Bot pairing and permission management (Discord user → Mosaic user mapping)
- DM support for private conversations
@@ -748,10 +843,12 @@ Telegram remote control channel.
### FR-9: Remote Control — Discord
- Discord bot that connects to the gateway
- Mention-based activation in channels
- Discord bot that connects to the gateway through a transport-neutral channel adapter contract
- Authorized messages in configured agent-bound channels work without a mention and respond in-channel
- Mentions in parent channels create threads, or reuse a thread already attached to that same native message, for multi-turn conversations
- Messages already in a thread remain there without requiring repeated mentions
- Stable logical-agent/channel conversation identity survives underlying harness/provider changes
- DM support for private conversations
- Thread creation for multi-turn conversations
- Chunked message delivery (Discord 2000-char limit)
- Bot configuration via web dashboard
- Permission management (which Discord users/roles can interact)
@@ -935,10 +1032,13 @@ Telegram remote control channel.
### AC-3: Discord Remote Control
- [ ] Discord bot connects and responds to mentions
- [ ] Messages route through gateway to agent pool
- [ ] Discord bot connects through the harness-neutral channel contract
- [ ] Authorized untagged channel messages route through the gateway and respond in-channel
- [ ] Mentioned parent-channel messages create a thread (or reuse their already-attached thread) and respond there
- [ ] Existing-thread follow-ups stay in the thread without repeated mentions
- [ ] Channel/session identity remains stable while the underlying harness/provider changes
- [ ] Responses stream back to Discord (chunked)
- [ ] Thread creation for multi-turn conversations
- [ ] Unauthorized guilds, channels, users, pairings, and roles create no thread and dispatch no message
### AC-4: Gateway Orchestration
@@ -1137,7 +1237,7 @@ All work is **alpha** (< 0.1.0) until Jason approves 0.1.0 beta release.
6. ASSUMPTION: **Log summarization uses Haiku-tier LLM by default, configurable.** Haiku is well-suited for summarization (compression, not generation — source material is in context). Guardrails: structured output via Zod schema (force extraction of decisions/tools/outcomes/errors as discrete fields), chunked per-session processing (no bulk conflation), extraction-focused prompts. Raw logs stay in hot tier (7 days) as safety net. Users can override the summarization model via routing engine config if they want higher fidelity. Rationale: Haiku is 10-20x cheaper than Sonnet; log summarization runs on schedule against large volumes where cost matters.
7. ASSUMPTION: **Discord plugin starts minimal and single-guild only**DM support, mention-based channel activation, thread management, chunked responses. Single guild binding to prevent data leaks between servers. Advanced features (voice, components, slash commands, multi-guild) are post-beta. Rationale: Proven pattern from OpenClaw; ship core interaction first; data isolation is non-negotiable.
7. ASSUMPTION: **Discord plugin starts minimal and single-guild only**explicitly configured agent-bound channels accept authorized untagged messages in-channel, while mentions create threads or reuse a thread already attached to that same native message; responses are chunked. Single guild binding prevents data leaks between servers. DM support, voice, components, slash commands, and multi-guild operation are post-beta. Rationale: Ship the requested core interaction model while preserving default-deny data isolation.
8. ASSUMPTION: **Telegram plugin is lower priority than Discord** and may ship as v0.0.7 or later if Discord takes longer than expected. Rationale: Jason indicated Discord as the high-priority remote channel.

View File

@@ -1,5 +1,20 @@
# 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.
- [Discord administrator configuration](guides/admin-guide.md#discord-ingress-security) — secrets, allowlists, bindings, role policy, and thread permissions.
- [Discord user workflow](tess/USER-GUIDE.md#discord-conversations) — in-channel messages, mention-created threads, and runtime-transparent continuity.
- [Channel plugin authoring](tess/PLUGIN-GUIDE.md#official-channel-adapter-contract) — requirements for future Matrix, Slack, and other official adapters.
- [Discord package guide](../plugins/discord/README.md) — package behavior, configuration shape, and development commands.
## Native Kanban and canonical task SOT
- [Canonical requirements](requirements/native-kanban-sot.md) — ratified P0P3 requirements and acceptance criteria.
@@ -48,3 +63,5 @@
- [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

@@ -53,10 +53,10 @@ Active workstream is **W1 — Federation v1**. Workers should:
> 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 | 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-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 |

View File

@@ -1,9 +1,9 @@
# Channel Protocol Architecture
**Status:** Draft
**Status:** Official adapter baseline implemented by #756; extended registry/multiplexing remains iterative
**Authors:** Mosaic Core Team
**Last Updated:** 2026-03-22
**Covers:** M7-001 (IChannelAdapter interface), M7-002 (ChannelMessage protocol), M7-003 (Matrix integration design), M7-004 (conversation multiplexing), M7-005 (remote auth bridging), M7-006 (agent-to-agent communication via Matrix), M7-007 (multi-user isolation in Matrix)
**Last Updated:** 2026-07-14
**Covers:** M7-001 (OfficialChannelAdapter interface), M7-002 (ChannelMessageDto protocol), M7-003 (Matrix integration design), M7-004 (conversation multiplexing), M7-005 (remote auth bridging), M7-006 (agent-to-agent communication via Matrix), M7-007 (multi-user isolation in Matrix)
---
@@ -11,93 +11,80 @@
The channel protocol defines a unified abstraction layer between Mosaic's core messaging infrastructure and the external communication channels it supports (Matrix, Discord, Telegram, TUI, WebUI, and future channels).
The protocol consists of two main contracts:
The implemented baseline is exported from `@mosaicstack/types` and consists of four contract groups:
1. `IChannelAdapter` — the interface each channel driver must implement.
2. `ChannelMessage` — the canonical message format that flows through the system.
1. `OfficialChannelAdapter` — transport lifecycle and connection health.
2. `ChannelMessageDto` / `ChannelAttachmentDto` — canonical transport data.
3. `ChannelConversationRouteDto` — stable logical-agent conversation and authorization address.
4. `ChannelResponseTargetDto` — channel/thread destination for replies.
All channel-specific translation logic lives inside the adapter implementation. The rest of Mosaic works exclusively with `ChannelMessage` objects.
All channel-specific translation logic lives inside the adapter implementation. Runtime selection does not: gateway durable-session and provider services may rebind the logical session from Claude to Codex, Pi, OpenCode, or another harness without reconnecting the channel adapter.
---
## M7-001: IChannelAdapter Interface
## M7-001: OfficialChannelAdapter Interface
```typescript
interface IChannelAdapter {
/**
* Stable, lowercase identifier for this channel (e.g. "matrix", "discord").
* Used as a namespace key in registry lookups and log metadata.
*/
interface OfficialChannelAdapter {
/** Stable, lowercase adapter identifier such as "discord" or "matrix". */
readonly name: string;
/**
* Establish a connection to the external channel backend.
* Called once at application startup. Must be idempotent (safe to call
* when already connected).
*/
connect(): Promise<void>;
/**
* Gracefully disconnect from the channel backend.
* Must flush in-flight sends and release resources before resolving.
*/
disconnect(): Promise<void>;
/**
* Return the current health of the adapter connection.
* Used by the admin health endpoint and alerting.
*
* - "connected" — fully operational
* - "degraded" — partial connectivity (e.g. read-only, rate-limited)
* - "disconnected" — no connection to channel backend
*/
health(): Promise<{ status: 'connected' | 'degraded' | 'disconnected' }>;
/**
* Register an inbound message handler.
* The adapter calls `handler` for every message received from the channel.
* Multiple calls replace the previous handler (last-write-wins).
* The handler is async; the adapter must not deliver new messages until
* the previous handler promise resolves (back-pressure).
*/
onMessage(handler: (msg: ChannelMessage) => Promise<void>): void;
/**
* Send a ChannelMessage to the given channel/room/conversation.
* `channelId` is the channel-native identifier (e.g. Matrix room ID,
* Discord channel snowflake, Telegram chat ID).
*/
sendMessage(channelId: string, msg: ChannelMessage): Promise<void>;
/**
* Map a channel-native user identifier to the Mosaic internal userId.
* Returns null when no matching Mosaic account exists for the given
* channelUserId (anonymous or unlinked user).
*/
mapIdentity(channelUserId: string): Promise<string | null>;
/** Establish both native-channel and gateway connections. */
start(): Promise<void>;
/** Gracefully close connections and release resources. */
stop(): Promise<void>;
/** Best-effort health; ordinary disconnection is a result, not an exception. */
health(): Promise<{
status: 'connected' | 'degraded' | 'disconnected';
detail?: string;
}>;
}
```
The small lifecycle seam lets the gateway host official plugins uniformly without moving native message translation into gateway core. Message ingress remains adapter-owned; gateway policy, durable session routing, auditing, and runtime/provider selection remain gateway-owned.
### Stable conversation route
```typescript
interface ChannelConversationRouteDto {
bindingId: string;
logicalAgentId: string;
conversationId: string;
channelName: string;
authorizationChannelId: string;
responseTarget: { channelId: string; threadId?: string };
}
```
Harness, provider, model, process, and native runtime-session identifiers are forbidden from this route. Runtime adapters consume the gateway's durable logical-session binding; channel adapters consume only the stable route and response target.
### Typed ingress and egress ports
`ChannelIngressPort` is the transport-neutral direct-integration seam for official adapters. The current deployed Discord adapter preserves its existing HMAC-signed Socket.IO compatibility ingress so gateway-side service authentication, replay protection, approval handling, and correlation semantics remain unchanged; it normalizes the same `ChannelIngressDto` before signing. The adapter uses a supplied `ChannelIngressPort` directly when a future gateway registration provides one. New adapters must use the shared ports rather than adding channel branches to gateway core.
`ChannelBindingDto` contains the configuration-owned workspace/channel→logical-agent mapping and paired external principals; credentials are absent. After native allowlist, pairing, and role checks pass, an adapter submits `ChannelIngressDto` to `ChannelIngressPort.receive()`. It includes the normalized message, `ChannelAuthorizedPrincipalDto`, operation, correlation ID, native message ID, and stable route. Unauthorized input never reaches the port.
Gateway policy and runtime routing produce `ChannelEgressDto`, which `ChannelEgressPort.send()` delivers to the route's response target. Discord's existing HMAC envelope is its authenticated wire encoding of this boundary; future Matrix/Slack adapters use their native authenticated transports while preserving the same actor/operation/correlation semantics.
### Adapter Registration
Adapters are registered with the `ChannelRegistry` service at startup. The registry calls `connect()` on each adapter and monitors `health()` on a configurable interval (default: 30 s).
Adapters are registered with the gateway plugin host at startup. The host calls `start()`/`stop()` and may monitor `health()` on a configurable interval. A richer dynamic `ChannelRegistry` remains a compatible future extension of this lifecycle contract.
```
ChannelRegistry
└── register(adapter: IChannelAdapter): void
└── getAdapter(name: string): IChannelAdapter | null
└── listAdapters(): IChannelAdapter[]
└── register(adapter: OfficialChannelAdapter): void
└── getAdapter(name: string): OfficialChannelAdapter | null
└── listAdapters(): OfficialChannelAdapter[]
└── healthAll(): Promise<Record<string, AdapterHealth>>
```
---
## M7-002: ChannelMessage Protocol
## M7-002: ChannelMessageDto Protocol
### Canonical Message Format
```typescript
interface ChannelMessage {
interface ChannelMessageDto {
/**
* Globally unique message ID.
* Format: UUID v4. Generated by the adapter when receiving, or by Mosaic
@@ -110,6 +97,7 @@ interface ChannelMessage {
* The adapter populates this from the inbound message.
* For outbound messages, the caller supplies the target channel.
*/
channelName: string;
channelId: string;
/**
@@ -119,7 +107,7 @@ interface ChannelMessage {
senderId: string;
/** Sender classification. */
senderType: 'user' | 'agent' | 'system';
senderKind: 'user' | 'agent' | 'system';
/**
* Textual content of the message.
@@ -136,7 +124,7 @@ interface ChannelMessage {
* - "image" — binary image; content is empty, see attachments
* - "file" — binary file; content is empty, see attachments
*/
contentType: 'text' | 'markdown' | 'code' | 'image' | 'file';
contentKind: 'text' | 'markdown' | 'code' | 'image' | 'file';
/**
* Arbitrary key-value metadata for channel-specific extension fields.
@@ -144,7 +132,7 @@ interface ChannelMessage {
* Adapters should store channel-native IDs here so round-trip correlation
* is possible without altering the canonical fields.
*/
metadata: Record<string, unknown>;
metadata: Readonly<Record<string, ChannelMetadataValue>>;
/**
* Optional thread or reply-chain identifier.
@@ -163,18 +151,21 @@ interface ChannelMessage {
* Binary or URI-referenced attachments.
* Each attachment carries its MIME type and a URL or base64 payload.
*/
attachments?: ChannelAttachment[];
attachments?: readonly ChannelAttachmentDto[];
/** Wall-clock timestamp when the message was sent/received. */
timestamp: Date;
/** ISO-8601 wall-clock timestamp when the message was sent/received. */
timestamp: string;
}
interface ChannelAttachment {
/** Filename or identifier. */
interface ChannelAttachmentDto {
/** Channel-native attachment identifier. */
id: string;
/** Filename or display name. */
name: string;
/** MIME type (e.g. "image/png", "application/pdf"). */
mimeType: string;
/** MIME type when supplied by the channel. */
mimeType: string | null;
/**
* URL pointing to the attachment, OR a `data:` URI with base64 payload.
@@ -192,18 +183,18 @@ interface ChannelAttachment {
## Channel Translation Reference
The following sections document how each supported channel maps its native message format to and from `ChannelMessage`.
The following sections document how each supported channel maps its native message format to and from `ChannelMessageDto`.
### Matrix
| ChannelMessage field | Matrix equivalent |
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| ChannelMessageDto field | Matrix equivalent |
| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| `id` | Generated UUID; `metadata.channelMessageId` = Matrix event ID (`$...`) |
| `channelId` | Matrix room ID (`!roomid:homeserver`) |
| `senderId` | Matrix user ID (`@user:homeserver`) |
| `senderType` | Always `"user"` for inbound; `"agent"` or `"system"` for outbound |
| `senderKind` | Always `"user"` for inbound; `"agent"` or `"system"` for outbound |
| `content` | `event.content.body` |
| `contentType` | `"markdown"` if `msgtype = m.text` and body contains markdown; `"text"` otherwise; `"image"` for `m.image`; `"file"` for `m.file` |
| `contentKind` | `"markdown"` if `msgtype = m.text` and body contains markdown; `"text"` otherwise; `"image"` for `m.image`; `"file"` for `m.file` |
| `threadId` | `event.content['m.relates_to']['event_id']` when `rel_type = m.thread` |
| `replyToId` | Mosaic ID looked up from `event.content['m.relates_to']['m.in_reply_to']['event_id']` |
| `attachments` | Populated from `url` in `m.image` / `m.file` events |
@@ -216,21 +207,34 @@ The following sections document how each supported channel maps its native messa
### Discord
| ChannelMessage field | Discord equivalent |
| -------------------- | ----------------------------------------------------------------------- |
| ChannelMessageDto field | Discord equivalent |
| ----------------------- | ----------------------------------------------------------------------- |
| `id` | Generated UUID; `metadata.channelMessageId` = Discord message snowflake |
| `channelId` | Discord channel ID (snowflake string) |
| `senderId` | Discord user ID (snowflake) |
| `senderType` | `"user"` for human members; `"agent"` for bot messages |
| `senderKind` | `"user"` for human members; `"agent"` for bot messages |
| `content` | `message.content` |
| `contentType` | `"markdown"` (Discord uses a markdown-like syntax natively) |
| `contentKind` | `"markdown"` (Discord uses a markdown-like syntax natively) |
| `threadId` | `message.thread.id` when the message is inside a thread channel |
| `replyToId` | Mosaic ID looked up from `message.referenced_message.id` |
| `attachments` | `message.attachments` mapped to `ChannelAttachment` |
| `attachments` | `message.attachments` mapped to `ChannelAttachmentDto` |
| `timestamp` | `new Date(message.timestamp)` |
| `metadata` | `{ channelMessageId, guildId, channelType, mentions, embeds }` |
**Outbound:** Adapter calls Discord REST `POST /channels/{id}/messages`. Markdown content is sent as-is (Discord renders it). For `contentType = "code"` the adapter wraps in triple-backtick fences with the `metadata.language` tag.
**Outbound:** Adapter calls Discord REST `POST /channels/{id}/messages`. Markdown content is sent as-is (Discord renders it). For `contentKind = "code"` the adapter wraps in triple-backtick fences with the `metadata.language` tag.
### Discord routing and thread policy
A configured Discord binding maps `(guildId, parentChannelId)` to a stable logical agent and a trusted gateway agent-config ID. Gateway verifies that configuration's name matches the binding logical agent before session creation. The stable conversation handle is derived from logical agent plus response channel/thread and never includes the active harness, provider, model, process, or agent-config ID.
| Inbound location/trigger | Conversation and response target |
| ------------------------------------------ | --------------------------------------------------------------- |
| Authorized untagged parent-channel message | Parent channel; response is sent in-channel |
| Authorized bot mention in parent channel | Thread already attached to that message, or a new public thread |
| Authorized message already in a thread | Existing thread; no repeated mention and no nested thread |
| `/approve` or `/stop <approval>` | Current parent/thread durable session; no new topic is created |
Authorization order is fixed: guild allowlist → parent-channel allowlist → user allowlist → configured binding/pairing → operation role → per-user/channel message and thread rate limits → thread creation/dispatch. A normal Discord channel's category parent is never treated as the thread authorization parent. If requested thread creation fails, dispatch does not occur because the adapter cannot honor the response target.
### Discord service ingress security
@@ -240,21 +244,21 @@ The Discord adapter is an authenticated gateway service, not an anonymous Socket
### Telegram
| ChannelMessage field | Telegram equivalent |
| -------------------- | ------------------------------------------------------------------------------------------------------------- |
| ChannelMessageDto field | Telegram equivalent |
| ----------------------- | ------------------------------------------------------------------------------------------------------------- |
| `id` | Generated UUID; `metadata.channelMessageId` = Telegram `message_id` (integer) |
| `channelId` | Telegram `chat_id` (integer as string) |
| `senderId` | Telegram `from.id` (integer as string) |
| `senderType` | `"user"` for human senders; `"agent"` for bot-originated messages |
| `senderKind` | `"user"` for human senders; `"agent"` for bot-originated messages |
| `content` | `message.text` or `message.caption` |
| `contentType` | `"text"` for plain; `"markdown"` if `parse_mode = MarkdownV2`; `"image"` for `photo`; `"file"` for `document` |
| `contentKind` | `"text"` for plain; `"markdown"` if `parse_mode = MarkdownV2`; `"image"` for `photo`; `"file"` for `document` |
| `threadId` | `message.message_thread_id` (for supergroup topics) |
| `replyToId` | Mosaic ID looked up from `message.reply_to_message.message_id` |
| `attachments` | `photo`, `document`, `video` fields mapped to `ChannelAttachment` |
| `attachments` | `photo`, `document`, `video` fields mapped to `ChannelAttachmentDto` |
| `timestamp` | `new Date(message.date * 1000)` |
| `metadata` | `{ channelMessageId, chatType, fromUsername, forwardFrom }` |
**Outbound:** Adapter calls Telegram Bot API `sendMessage` with `parse_mode = MarkdownV2` for markdown content. For `contentType = "image"` or `"file"` it uses `sendPhoto` / `sendDocument`.
**Outbound:** Adapter calls Telegram Bot API `sendMessage` with `parse_mode = MarkdownV2` for markdown content. For `contentKind = "image"` or `"file"` it uses `sendPhoto` / `sendDocument`.
---
@@ -262,14 +266,14 @@ The Discord adapter is an authenticated gateway service, not an anonymous Socket
The TUI adapter bridges Mosaic's terminal interface (`packages/cli`) to the channel protocol so that TUI sessions can be treated as a first-class channel.
| ChannelMessage field | TUI equivalent |
| -------------------- | ------------------------------------------------------------------ |
| ChannelMessageDto field | TUI equivalent |
| ----------------------- | ------------------------------------------------------------------ |
| `id` | Generated UUID (TUI has no native message IDs) |
| `channelId` | `"tui:<conversationId>"` — the active conversation ID |
| `senderId` | Authenticated Mosaic `userId` |
| `senderType` | `"user"` for human input; `"agent"` for agent replies |
| `senderKind` | `"user"` for human input; `"agent"` for agent replies |
| `content` | Raw text from stdin / agent output |
| `contentType` | `"text"` for input; `"markdown"` for agent responses |
| `contentKind` | `"text"` for input; `"markdown"` for agent responses |
| `threadId` | Not used (TUI sessions are linear) |
| `replyToId` | Not used |
| `attachments` | File paths dragged/pasted into the TUI; resolved to `file://` URLs |
@@ -284,14 +288,14 @@ The TUI adapter bridges Mosaic's terminal interface (`packages/cli`) to the chan
The WebUI adapter connects the Next.js frontend (`apps/web`) to the channel protocol over the existing Socket.IO gateway (`apps/gateway`).
| ChannelMessage field | WebUI equivalent |
| -------------------- | ------------------------------------------------------------ |
| ChannelMessageDto field | WebUI equivalent |
| ----------------------- | ------------------------------------------------------------ |
| `id` | Generated UUID; echoed back in the WebSocket event |
| `channelId` | `"webui:<conversationId>"` |
| `senderId` | Authenticated Mosaic `userId` |
| `senderType` | `"user"` for browser input; `"agent"` for agent responses |
| `senderKind` | `"user"` for browser input; `"agent"` for agent responses |
| `content` | Message text from the input field |
| `contentType` | `"text"` or `"markdown"` |
| `contentKind` | `"text"` or `"markdown"` |
| `threadId` | Not used (conversation model handles threading) |
| `replyToId` | Message ID the user replied to (UI reply affordance) |
| `attachments` | Files uploaded via the file picker; stored to object storage |
@@ -304,7 +308,7 @@ The WebUI adapter connects the Next.js frontend (`apps/web`) to the channel prot
## Identity Mapping
`mapIdentity(channelUserId)` resolves a channel-native user identifier to a Mosaic `userId`. This is required to attribute inbound messages to authenticated Mosaic accounts.
Gateway identity-linking policy resolves a channel-native user identifier to a Mosaic `userId` and produces `ChannelAuthorizedPrincipalDto`. Adapters provide native identity evidence but cannot self-authorize Mosaic scope. Discord currently uses configuration-owned paired users; database-backed linking remains the canonical direction for dynamic Matrix/Slack identity.
The implementation must query a `channel_identities` table (or equivalent) keyed on `(channel_name, channel_user_id)`. When no mapping exists the method returns `null` and the message is treated as anonymous (no Mosaic session context).
@@ -323,8 +327,8 @@ Identity linking flows (OAuth dance, deep-link verification token, etc.) are out
## Error Handling Conventions
- `connect()` must throw a structured error (subclass of `ChannelConnectError`) if the initial connection cannot be established within a reasonable timeout (default: 10 s).
- `sendMessage()` must throw `ChannelSendError` on terminal failures (auth revoked, channel not found). Transient failures (rate limit, network blip) should be retried internally with exponential backoff before throwing.
- `start()` must establish the native channel transport or throw a structured connection error. An adapter hosted inside the gateway must not wait for a loopback connection to that same not-yet-listening process; it starts the native transport, lets Socket.IO reconnect, and reports `degraded` until both links are ready.
- `ChannelEgressPort.send()` implementations must throw a typed terminal error for revoked auth, an invalid route, or a missing channel. Only transient rate/network/server failures are retried with bounded exponential backoff; Discord retries reuse a stable enforced nonce to prevent duplicate chunks, while permanent 4xx failures are not retried.
- `health()` must never throw — it returns `{ status: 'disconnected' }` on error.
- Adapters must emit structured logs with `{ channel: adapter.name, event, ... }` metadata for observability.
@@ -332,7 +336,7 @@ Identity linking flows (OAuth dance, deep-link verification token, etc.) are out
## Versioning
The `ChannelMessage` protocol follows semantic versioning. Non-breaking field additions (new optional fields) are minor version bumps. Breaking changes (type changes, required field additions) require a major version bump and a migration guide.
The `ChannelMessageDto` protocol follows semantic versioning. Non-breaking field additions (new optional fields) are minor version bumps. Breaking changes (type changes, required field additions) require a major version bump and a migration guide.
Current version: **1.0.0**
@@ -473,7 +477,7 @@ A single Mosaic conversation can be accessed simultaneously from multiple surfac
### Real-Time Sync Flow
1. A message arrives on any surface (TUI keystroke, browser send, Matrix event).
2. The surface's adapter normalizes the message to `ChannelMessage` and delivers it to `ConversationService`.
2. The surface's adapter normalizes the message to `ChannelMessageDto` and delivers it to `ConversationService`.
3. `ConversationService` persists the message to PostgreSQL, assigns a canonical `id`, and publishes a `message:new` event to the Valkey pub/sub channel keyed by `conversationId`.
4. All active surfaces subscribed to that `conversationId` receive the fanout event and push it to their respective clients:
- TUI adapter: writes rendered output to the connected terminal session.
@@ -561,7 +565,7 @@ Matrix sessions for linked users are persistent and long-lived. Unlike TUI sessi
- Their `channel_identities` row exists (link not revoked).
- They remain members of the relevant Matrix rooms.
Revoking a Matrix link (`DELETE /auth/channel-link/matrix/<matrixUserId>`) removes the `channel_identities` row and causes `mapIdentity()` to return `null`. The appservice optionally kicks the Matrix user from all Mosaic-managed rooms as part of the revocation flow (configurable, default: off).
Revoking a Matrix link (`DELETE /auth/channel-link/matrix/<matrixUserId>`) removes the `channel_identities` row and causes gateway principal resolution to deny the identity. The appservice optionally kicks the Matrix user from all Mosaic-managed rooms as part of the revocation flow (configurable, default: off).
---
@@ -733,7 +737,7 @@ room_retention_policies
created_at TIMESTAMP
```
The retention policy is enforced by a background job in the gateway that calls Conduit's admin API to purge events older than the configured threshold. Purged events are removed from the Conduit store but Mosaic's PostgreSQL message store retains the canonical `ChannelMessage` record unless the Mosaic retention policy also covers it.
The retention policy is enforced by a background job in the gateway that calls Conduit's admin API to purge events older than the configured threshold. Purged events are removed from the Conduit store but Mosaic's PostgreSQL message store retains the canonical `ChannelMessageDto` record unless the Mosaic retention policy also covers it.
Default retention values:

View File

@@ -0,0 +1,49 @@
# 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,114 +1,77 @@
# Fleet Launch Runbook
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.
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.
## The launch chain
## Launch chain
| 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`. |
| 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. |
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.
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.
## Worker launch path (default)
## Generated and local files
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.
`<name>.env.generated` is complete, deterministic, and written only by Mosaic. Its ordered keys are:
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>'
```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>
```
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.
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.
## Launch gotchas
`<name>.env.local` is optional and may contain only non-secret machine data:
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.
- `MOSAIC_RUNTIME_BIN`
- `MOSAIC_HEARTBEAT_RUN_DIR`
- `MOSAIC_HEARTBEAT_INTERVAL`
- `MOSAIC_CLAUDE_JSON`
- `CLAUDE_CONFIG_DIR`
## Why per-agent `.env` survives upgrades (#632)
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.
`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.
## Legacy input and diagnostics
## Inspecting the comms wiring
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.
- `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.
Diagnostics expose only rule code, key name, and a SHA-256 content hash. They do not reveal command
text, credentials, or other values.
## North Star / future direction
## Launch and stop behavior
**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.
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.
**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:
`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.
| 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. |
## Current M2 boundary
**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.
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.

View File

@@ -7,7 +7,8 @@ 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.
parallel resolver. The current executable implementation and per-artifact outcomes are recorded in
[the disposition evidence](./migration/example-profile-disposition.md).
## Examples

View File

@@ -0,0 +1,74 @@
# Create, Inspect, Update, and Delete a Local Fleet Agent
Use the local roster-v2 control plane only. These commands change desired state and derived environment projections; they never start, stop, reconcile, inspect, or otherwise act on systemd, tmux, sessions, or runtimes.
## Read and plan first
```sh
mosaic fleet get <name>
mosaic fleet plan create --expected-generation <n> --agent '<json>'
mosaic fleet plan update <name> --expected-generation <n> --agent '<json>'
mosaic fleet plan delete <name> --expected-generation <n>
```
`plan create` takes the name from `--agent`. `plan update` and `plan delete` require the target name immediately after the operation. A plan is deterministic and side-effect free: it validates the complete proposed roster and projection targets without changing files. Use `--dry-run` on `create`, `update`, or `delete` for the same no-write result.
Every successful command prints JSON. `get` returns `{ "generation", "agent" }`; mutation results contain `plan`, `applied`, `authoritativeRoster`, and `projections`.
## Create safely
```sh
mosaic fleet create --expected-generation 7 --agent '{
"name":"coder0",
"alias":"Coder 0",
"className":"code",
"runtime":"pi",
"provider":"openai",
"model":"gpt-5.6-sol",
"reasoning":"high",
"toolPolicy":"code",
"workingDirectory":"/srv/mosaic",
"persistentPersona":false,
"resetBetweenTasks":true,
"launch":{"yolo":true}
}'
```
Create defaults to `enabled: true` and `desired_state: stopped`. It does not start a process. Add `--persisted-start` only to persist `desired_state: running`; that still does not start a runtime in this M2 command. The JSON payload is an allowlist of the roster-v2 fields shown above plus `launch.yolo`; command, channel, secret-reference, and other unknown keys are rejected rather than ignored. The JSON error exposes only a stable code, never the rejected value.
## Update and delete safely
```sh
mosaic fleet update coder0 --expected-generation 8 --agent '<complete JSON agent payload>'
mosaic fleet delete coder0 --expected-generation 9
```
Updates require a complete agent JSON payload and preserve the stable name. Delete removes only the exact roster-owned `coder0.env.generated` projection. It retains `coder0.env.local`, legacy `coder0.env`, `coder0.env.quarantine`, and every unrelated projection. A delete dry-run leaves all of those files byte-identical.
## Handle generation conflicts
Every mutation requires the current authoritative `--expected-generation`. A stale value returns JSON `error.code: "stale-generation"` with a non-zero exit. Reload with `mosaic fleet get <name>` or reread the roster, plan again using the returned generation, then retry. A concurrent mutation returns `concurrent-mutation`; do not force or bypass the lock.
## Interpret partial failures
The roster is authoritative and is written before derived projections. A late projection I/O failure returns non-zero with redacted, actionable JSON:
```json
{
"applied": false,
"authoritativeRoster": "committed",
"projections": "incomplete",
"recovery": {
"code": "projection-apply-failed",
"action": "regenerate-projections-from-roster"
}
}
```
This is not a rollback and not a no-op: reload the roster because its generation and membership were committed, regenerate projections from that roster, then plan a new mutation. Recovery output never contains environment values, credentials, or command text.
## Exit and boundary behavior
Handled validation errors and partial projection failures exit non-zero. `plan`/`--dry-run` and normal mutation JSON make the state explicit; scripts should use both the exit code and `authoritativeRoster`/`projections`, not `applied` alone.
The commands operate only on `<mosaic-home>/fleet/roster.yaml`, the local roster desired-state authority. They do not accept arbitrary commands, channels, secrets, remote/connector actions, migration/canary actions, or runtime lifecycle operations.

View File

@@ -0,0 +1,54 @@
# 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

@@ -0,0 +1,52 @@
# 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

@@ -0,0 +1,40 @@
# 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

@@ -0,0 +1,43 @@
# Local Fleet Agent Mutations
FCM-M2-002 provides local roster-v2 create, get, update, delete, and plan operations. They only change desired state and derived environment projections. They never start, stop, inspect, reconcile, or otherwise act on runtimes, systemd units, tmux sessions, or heartbeats.
## CLI contract
The commands operate only on the canonical `<mosaic-home>/fleet/roster.yaml` v2 authority and print one JSON object to stdout. `--agent` is a JSON object with the roster agent fields expressed as `className`, `toolPolicy`, `workingDirectory`, `persistentPersona`, `resetBetweenTasks`, and `launch: { "yolo": boolean }`.
```sh
mosaic fleet get <name>
mosaic fleet plan <create|update|delete> [name] --expected-generation <n> [--agent '<json>'] [--persisted-start]
mosaic fleet create --expected-generation <n> --agent '<json>' [--dry-run] [--persisted-start]
mosaic fleet update <name> --expected-generation <n> --agent '<json>' [--dry-run]
mosaic fleet delete <name> --expected-generation <n> [--dry-run]
```
`get` returns the authoritative generation and the selected agent. `plan create` derives its name from `--agent`; `plan update <name>` and `plan delete <name>` require the target name. `--agent` accepts only the documented roster-v2 request fields and `launch.yolo`; unknown keys such as commands, channels, or secret references are rejected. Rejection diagnostics return only the stable `invalid-request` code and never echo a rejected value. `plan` and `--dry-run` validate the complete proposed roster and projections but write neither the roster nor projections. `--persisted-start` is available only for a create request: it records `desired_state: running`, but does not start a process. Without it, create records `enabled: true` and `desired_state: stopped`. Handled failures return JSON with `error.code` and exit non-zero; unclassified validation/projection failures use the redacted `mutation-failed` code.
## Generation, validation, and idempotency
Each create, update, or delete request includes `expectedGeneration`. A request whose expected value differs from the authoritative roster generation fails with `stale-generation`; reload and retry with a newly computed plan. A private mutation lock rejects concurrent writers with `concurrent-mutation`.
`planFleetAgentMutation` is deterministic and side-effect free. `executeFleetAgentMutation` validates the complete proposed roster through the existing structural and shared persona resolver, prepares generated/local/quarantine projections, and writes the roster authority atomically before applying derived projections. Equivalent create retries and delete requests for an already-absent agent are idempotent no-ops.
Delete removes only the exact `<name>.env.generated` projection for the removed roster entry. Operator-owned `<name>.env.local`, legacy `<name>.env`, quarantine records, and unrelated projections remain untouched. An already-absent generated projection is treated as stale derived state, not as a failed mutation.
## Result and recovery
Mutation results are JSON-safe objects with `applied`, `authoritativeRoster`, `projections`, `plan`, and—only if a derived projection write fails after the authoritative roster write—a recovery object. `applied` is true only when every roster and derived-projection write completed. The explicit state fields prevent a partial result from being mistaken for a rollback or a no-op:
```json
{
"applied": false,
"authoritativeRoster": "committed",
"projections": "incomplete",
"recovery": {
"code": "projection-apply-failed",
"action": "regenerate-projections-from-roster"
}
}
```
Dry-runs and idempotent no-ops report `authoritativeRoster: "unchanged"` and `projections: "not-applied"`; a complete mutation reports `"committed"` and `"complete"`. Recovery output identifies the authoritative roster path and regeneration action only. It never contains generated/local/quarantine values, credentials, or command text. A recovery result exits non-zero because the authoritative roster was persisted but derived projections require regeneration. Regenerate projections from the roster before attempting another mutation.

View File

@@ -0,0 +1,96 @@
# 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

@@ -0,0 +1,45 @@
# 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

@@ -0,0 +1,124 @@
# Fleet Roster v2 Structural Contract
**Status:** FCM-M1-001 local-tmux structural compiler contract. This document describes parsing,
strict structural validation, normalized in-memory representation, and deterministic rendering only.
It does not authorize role resolution, lifecycle reconciliation, mutation, migration, remote
placement, connector configuration, secret references, arbitrary commands, channels, gateway
mapping, or any live-fleet change.
The executable schema is [`roster-v2.schema.json`](./roster-v2.schema.json). The compiler exports
the same schema and its test parses this file and compares it structurally with the executable contract.
## Format and canonical shape
The compiler accepts YAML or JSON. It reads only snake_case source fields and renders canonical,
snake_case YAML. Rendering sorts runtime keys and agents by stable name. Agent names, class names,
and tool-policy names are structural identifiers; whether a class or policy resolves is a later
shared-resolver concern.
```yaml
version: 2
generation: 1
transport: tmux
tmux:
socket_name: mosaic-fleet
holder_session: _holder
defaults:
working_directory: ~/src
runtime: pi
runtimes:
pi:
reset_command: /new
agents:
- name: coder0
alias: Coder 0
class: code
runtime: pi
provider: openai
model: gpt-5.6-sol
reasoning: high
tool_policy: code
working_directory: ~/src
persistent_persona: false
reset_between_tasks: true
lifecycle:
enabled: true
desired_state: stopped
launch:
yolo: true
```
## Root fields
| Field | Required | Constraint | Meaning |
| ------------ | -------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| `version` | yes | integer constant `2` | Identifies this contract. Version `1` is explicitly rejected by this compiler and remains on the existing v1 path until M4 migration. |
| `generation` | yes | positive safe integer | Desired-state generation. M2 uses it for mutation guards; M1 does not mutate it. |
| `transport` | yes | constant `tmux` | M1M5 support local tmux only. |
| `tmux` | yes | strict object | Explicit local socket and holder-session configuration. |
| `defaults` | yes | strict object | Default work directory and one supported local runtime. |
| `runtimes` | yes | non-empty object | Declared local runtime reset policy map. |
| `agents` | yes | non-empty array | Local fleet entries. Duplicate stable names are rejected. |
## Nested fields
| Path | Required | Constraint |
| ---------------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------- |
| `tmux.socket_name` | yes | non-empty `[A-Za-z0-9_.-]+`; an explicit named socket prevents default-versus-named socket ambiguity |
| `tmux.holder_session` | yes | non-empty `[A-Za-z0-9_.-]+` |
| `defaults.working_directory` | yes | non-empty string |
| `defaults.runtime` | yes | `claude`, `codex`, `opencode`, or `pi`; it must be declared in `runtimes` |
| `runtimes.<runtime>.reset_command` | yes | non-empty string; runtime key must be a supported local runtime |
| `agents[].name` | yes | unique `[A-Za-z0-9][A-Za-z0-9_.-]*` stable machine identity |
| `agents[].alias` | yes | non-empty display string |
| `agents[].class` | yes | `[a-z][a-z0-9-]*`; structural only in M1, semantic role resolution is FCM-M1-002 |
| `agents[].runtime` | yes | `claude`, `codex`, `opencode`, or `pi`; it must be declared in `runtimes` |
| `agents[].provider`, `model`, `working_directory` | yes | non-empty strings; provider/model capability resolution is a later card |
| `agents[].reasoning` | yes | `low`, `medium`, or `high` |
| `agents[].tool_policy` | yes | `[a-z][a-z0-9-]*`; structural only in M1 |
| `agents[].persistent_persona`, `reset_between_tasks` | yes | booleans |
| `agents[].lifecycle.enabled` | yes | boolean; stored now, reconciled in FCM-M3-001 |
| `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,
and wrong-type fields before producing a model. It specifically rejects remote/SSH/host/socket
per-agent fields, connector blocks, secret references, channel fields, arbitrary command fields,
and gateway fields because they are unsupported in the local-tmux M1 contract. It does not silently
ignore v1 camelCase input, version `1`, or a source that does not parse to an object.
The v2 compiler is intentionally isolated from the existing v1 loader. Existing v1 rosters and
current examples/profiles continue on their current path; FCM-M4 owns explicit inventory, preview,
migration, and rollback. FCM-M2 owns generated-file/local-override quarantine, and FCM-M3 owns
runtime lifecycle and reconciliation.

View File

@@ -0,0 +1,156 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://mosaicstack.dev/schemas/fleet/roster-v2.schema.json",
"title": "Mosaic local tmux fleet roster v2",
"type": "object",
"additionalProperties": false,
"required": ["version", "generation", "transport", "tmux", "defaults", "runtimes", "agents"],
"properties": {
"version": {
"const": 2
},
"generation": {
"type": "integer",
"minimum": 1,
"maximum": 9007199254740991
},
"transport": {
"const": "tmux"
},
"tmux": {
"type": "object",
"additionalProperties": false,
"required": ["socket_name", "holder_session"],
"properties": {
"socket_name": {
"type": "string",
"pattern": "^[A-Za-z0-9_.-]+$"
},
"holder_session": {
"type": "string",
"pattern": "^[A-Za-z0-9_.-]+$"
}
}
},
"defaults": {
"type": "object",
"additionalProperties": false,
"required": ["working_directory", "runtime"],
"properties": {
"working_directory": {
"type": "string",
"minLength": 1
},
"runtime": {
"enum": ["claude", "codex", "opencode", "pi"]
}
}
},
"runtimes": {
"type": "object",
"minProperties": 1,
"propertyNames": {
"enum": ["claude", "codex", "opencode", "pi"]
},
"additionalProperties": {
"type": "object",
"additionalProperties": false,
"required": ["reset_command"],
"properties": {
"reset_command": {
"type": "string",
"minLength": 1
}
}
}
},
"agents": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"name",
"alias",
"class",
"runtime",
"provider",
"model",
"reasoning",
"tool_policy",
"working_directory",
"persistent_persona",
"reset_between_tasks",
"lifecycle",
"launch"
],
"properties": {
"name": {
"type": "string",
"pattern": "^[A-Za-z0-9][A-Za-z0-9_.-]*$"
},
"alias": {
"type": "string",
"minLength": 1
},
"class": {
"type": "string",
"pattern": "^[a-z][a-z0-9-]*$"
},
"runtime": {
"enum": ["claude", "codex", "opencode", "pi"]
},
"provider": {
"type": "string",
"minLength": 1
},
"model": {
"type": "string",
"minLength": 1
},
"reasoning": {
"enum": ["low", "medium", "high"]
},
"tool_policy": {
"type": "string",
"pattern": "^[a-z][a-z0-9-]*$"
},
"working_directory": {
"type": "string",
"minLength": 1
},
"persistent_persona": {
"type": "boolean"
},
"reset_between_tasks": {
"type": "boolean"
},
"lifecycle": {
"type": "object",
"additionalProperties": false,
"required": ["enabled", "desired_state"],
"properties": {
"enabled": {
"type": "boolean"
},
"desired_state": {
"enum": ["running", "stopped"]
}
}
},
"launch": {
"type": "object",
"additionalProperties": false,
"required": ["yolo"],
"properties": {
"yolo": {
"type": "boolean"
}
}
}
}
}
}
}
}

View File

@@ -294,7 +294,7 @@ Each OIDC provider requires its client ID, client secret, and issuer URL togethe
### Plugins
| Variable | Description |
| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| `DISCORD_BOT_TOKEN` | Discord bot token (enables Discord plugin) |
| `DISCORD_SERVICE_TOKEN` | Required high-entropy service credential used to authenticate and sign Discord ingress; inject through the approved secret mechanism only |
| `DISCORD_SERVICE_USER_ID` | Required Mosaic service-principal user ID that owns persisted Discord conversations; the original Discord user ID remains audit metadata |
@@ -303,6 +303,9 @@ Each OIDC provider requires its client ID, client secret, and issuer URL togethe
| `DISCORD_ALLOWED_GUILD_IDS` | Required comma-separated Discord guild snowflake allowlist; default-deny |
| `DISCORD_ALLOWED_CHANNEL_IDS` | Required comma-separated Discord channel snowflake allowlist; default-deny |
| `DISCORD_ALLOWED_USER_IDS` | Required comma-separated Discord user snowflake allowlist; default-deny |
| `DISCORD_INTERACTION_BINDINGS` | Required JSON bindings from guild/channel to logical agent and paired Discord users with `viewer`, `operator`, or `admin` roles |
| `DISCORD_MESSAGE_RATE_LIMIT_PER_MINUTE` | Optional positive integer; authorized turns per guild/channel/user each minute (default: `30`) |
| `DISCORD_THREAD_RATE_LIMIT_PER_MINUTE` | Optional positive integer; mention-thread routes per guild/channel/user each minute (default: `5`) |
| `TELEGRAM_BOT_TOKEN` | Telegram bot token (enables Telegram plugin) |
| `TELEGRAM_GATEWAY_URL` | Gateway URL for Telegram plugin to call |
@@ -310,7 +313,44 @@ Each OIDC provider requires its client ID, client secret, and issuer URL togethe
When `DISCORD_BOT_TOKEN` is configured, `DISCORD_SERVICE_TOKEN`, `DISCORD_SERVICE_USER_ID`, and all three Discord allowlists are required. Gateway startup fails rather than enabling a broad or unauthenticated remote-control surface. The service user ID identifies a provisioned Mosaic service principal for persistence; the original Discord user ID is retained in ingress audit metadata. The service token is a secret supplied by the approved runtime secret mechanism and is never committed or logged.
Inbound Discord messages must originate from an allowed guild, channel, and user, mention the bot, and carry a signed envelope containing the native Discord message ID and a generated correlation ID. The gateway validates the service identity, envelope signature, and allowlists again before dispatching. Replayed Discord message IDs are rejected during the bounded ingress replay window. Durable inbox/idempotency retention is introduced with Tess durable state.
Inbound Discord messages must originate from an allowed guild and configured parent channel, come from an allowed and paired user whose role permits sending, and carry a signed envelope containing the native Discord message ID and a generated correlation ID. Attachment references are limited in count, metadata size, field length, and declared size; only query-free HTTPS URLs without credentials or fragments are accepted, so bearer or presigned URLs never reach persistence or an agent prompt. The gateway validates the service identity, envelope signature, allowlists, pairing, and role again before dispatching. Replayed Discord message IDs are rejected during the bounded ingress replay window. Durable inbox/idempotency retention is introduced with Tess durable state.
Configured channels are dedicated agent interaction surfaces. An authorized untagged message routes to the bound logical agent and the response returns in that channel. Mentioning the bot on a normal channel message creates a public Discord thread, or reuses the thread already attached to that same message; the response and later thread messages stay in that thread without repeated mentions. A normal channel's category is not an authorization parent—only a Discord thread inherits authorization from its configured parent channel. Runtime control commands such as `/approve` and `/stop <approval>` remain on the current channel/thread because they target that durable session rather than opening a new topic.
Authorization and per-user/channel rate limits are evaluated before thread creation, so an unlisted guild/channel/user, unpaired user, `viewer`, or rate-limited sender cannot create bot threads or dispatch gateway work. The bot needs Discord permissions to view/send in configured channels and create/send in public threads. If thread creation fails, the turn is not dispatched because the requested response destination cannot be honored.
Conversation handles use the configured logical agent plus Discord channel/thread identity. They do not contain a Claude, Codex, Pi, OpenCode, model, process, or runtime-provider identifier; changing the runtime behind the logical session therefore does not require reconnecting the Discord bot.
#### Interaction binding format
`DISCORD_INTERACTION_BINDINGS` must be a non-empty JSON array. Each item requires `instanceId`, trusted `agentConfigId`, `guildId`, `channelId`, and a non-empty `pairedUsers` object. `instanceId` is the configured logical-agent name; its trusted database `agentConfigId` must resolve to an agent configuration with exactly that name, preserving provider/model/prompt/tool selection per binding. The guild/channel must also appear in their corresponding allowlists. IDs below are placeholders:
```json
[
{
"instanceId": "interaction-agent",
"agentConfigId": "agent-config-id",
"guildId": "guild-id",
"channelId": "channel-id",
"pairedUsers": {
"discord-user-id": {
"role": "operator",
"mosaicUserId": "mosaic-user-id"
}
}
}
]
```
| Pairing role | Send message | Create/continue thread | Approve | Stop |
| ------------ | ------------ | ---------------------- | ------- | ---- |
| `viewer` | No | No | No | No |
| `operator` | Yes | Yes | No | No |
| `admin` | Yes | Yes | Yes | Yes |
A legacy role-only value such as `"discord-user-id": "operator"` remains valid for non-privileged ingress. Approval and stop require the object form with a provisioned `mosaicUserId`; gateway policy checks that Mosaic identity and consumes one exact-action approval once. Do not make the Discord service principal an approving administrator.
After changing bindings or allowlists, restart the gateway/plugin through the normal service manager and verify both Discord and gateway connectivity. The adapter reports `connected` only when both links are ready, `degraded` when one is ready, and `disconnected` when neither is ready. Test one authorized untagged channel turn, one mention-created thread, one thread follow-up, and one unauthorized user denial without using production credential values in logs or evidence.
### Session retention and garbage collection

View File

@@ -0,0 +1,43 @@
# 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

@@ -0,0 +1,27 @@
# Independent Code Review — #756 Official Discord Channel Plugin
**Verdict: APPROVE**
## Scope reviewed
Complete current uncommitted #756 delta: multi-binding trusted agent selection, privileged ingress-route validation, attachment validation/persistence/resume, egress route lifecycle and idempotency, concurrent gateway stream state, Discord lifecycle/thread/rate behavior, compatibility ingress, and tests.
## Review result
No blocking or change-request finding remains.
- **Trusted multi-agent routing:** each binding requires a provisioned `agentConfigId`; the gateway resolves that config server-side, verifies its logical-agent name, and never accepts a Discord-controlled agent selection or applies generic routing to Discord ingress.
- **Auth and route integrity:** allowlist, pairing, role, and canonical logical-agent/channel-or-thread conversation-route validation occur before gateway processing. Privileged approval/stop paths use the same binding and route validation.
- **Attachments:** ingress rejects malformed, over-bounded, credential-bearing, fragment-bearing, or query-bearing URLs. Valid attachment metadata, including `sizeBytes`, persists and is reconstructed into resume history as explicitly untrusted context.
- **Discord reliability:** the adapter supports degraded Socket.IO reconnect, parent/thread routing semantics, bounded pre-side-effect ingress rates, and terminal response-route cleanup. Egress validates route/message alignment, sends deterministic nonces, distinguishes permanent from transient errors, and uses bounded retries.
- **Concurrent state:** per-client/conversation keys isolate listener, redaction, tool, and stream state for simultaneous threads sharing a Discord socket; disconnect cleanup covers all associated conversations.
- **Harness neutrality:** contracts retain logical-agent/channel data only; no harness/provider identity leaks into adapter routes or message boundaries.
## Verification performed
- `git diff --check` — passed.
- `pnpm --filter @mosaicstack/discord-plugin typecheck` — passed.
- `pnpm --filter @mosaicstack/discord-plugin lint` — passed.
- `pnpm --filter @mosaicstack/discord-plugin test` — passed: 44 tests; coverage 92.18% statements/lines, 86.55% branches, 100% functions (all ≥85% threshold).
- `pnpm --filter @mosaicstack/gateway typecheck` — passed.
- `cd apps/gateway && pnpm exec vitest run src/plugin/discord-ingress.security.spec.ts src/chat/chat.gateway-redaction.spec.ts src/__tests__/integration/tess-cross-surface.integration.test.ts` — passed: 32 tests.

View File

@@ -0,0 +1,36 @@
# Documentation Completion Checklist — #756 Official Discord plugin
## Required artifacts
- [x] `docs/PRD.md` includes the #756 workstream, assumptions, and acceptance criteria.
- [x] User workflow updated in `docs/tess/USER-GUIDE.md`.
- [x] Administrator configuration and authorization policy updated in `docs/guides/admin-guide.md`.
- [x] Developer/plugin authoring guidance updated in `docs/tess/PLUGIN-GUIDE.md`.
- [x] Channel architecture updated in `docs/architecture/channel-protocol.md`.
- [x] Package operations/development guide added at `plugins/discord/README.md`.
- [x] `docs/SITEMAP.md` links the official channel plugin documentation.
## API coverage
- [x] No HTTP or WebSocket endpoint was added, removed, or changed.
- [x] No OpenAPI update is needed.
- [x] Shared TypeScript contracts are documented in architecture and plugin-authoring guides.
- [x] Discord authentication, authorization, thread failure, and control-command behavior are documented.
## Structural standards
- [x] Working notes remain under `docs/scratchpads/`.
- [x] Review and checklist artifacts remain under `docs/reports/`.
- [x] No generated publishing output was added.
- [x] Existing repository documentation structure was preserved; no unrelated root cleanup was attempted.
## Review gate
- [x] Independent documentation/contract review passes (shared-contract review plus final code review of the current documentation delta).
- [x] Independent code review verifies documentation matches implementation (`docs/reports/code-review/756-code-review.md`: APPROVE).
- [x] Independent security review verifies documented controls (`docs/reports/security/756-security-review.md`: APPROVE).
## Publishing
- [x] Canonical source remains in-repository.
- [x] No external publishing action is in scope for this slice.

View File

@@ -0,0 +1,37 @@
# Security Review — Issue #756
**Scope:** final current uncommitted Discord plugin, shared channel contract, gateway ingress, AgentService, and plugin registration delta
**Snapshot:** `plugins/discord/src/index.ts` SHA-256 `5ee6b6aa4e2ff349f137f76b918d02c1254e12066cb48b136048e57bef36fdb2`
**Verdict:** **APPROVE**
## Final remediation verification
| Area | Current evidence | Result |
|---|---|---|
| Attachment confidentiality and integrity | Ingress accepts bounded attachment metadata only when URL is HTTPS, query-free, fragment-free, and credential-free. Count, aggregate size, field length, MIME, and finite non-negative size validation apply before dispatch; `sizeBytes` is signed and retained. | Pass |
| Trusted agent selection | Each binding names a required trusted `agentConfigId`; gateway resolves that config server-side and requires its configured name to equal the binding logical-agent ID. Client/provider input cannot select the agent for Discord ingress. | Pass |
| Privileged operation routing | Gateway revalidates the binding and requires the signed conversation identity to match the configured logical agent before approve/stop actions. Paired admin identity and one-time approval checks remain enforced. | Pass |
| Egress containment and delivery | Egress requires an aligned message/route, configured parent or exact observed thread target, and cleans response routes after completion, errors, typed-ingress failure, or bounded-map pressure. | Pass |
| Side-effect limits | Per guild/authorized-parent/user rolling message and thread limits execute before thread creation or dispatch. | Pass |
## Security controls reviewed
- Default-deny guild, parent-channel, user, configured pairing, and role checks precede rate consumption and all side effects.
- Gateway independently enforces Discord service authentication, HMAC integrity, allowlists, binding/role checks, attachment validation, and replay-ID rejection.
- Thread authorization uses only the Discord thread parent; category parents cannot authorize ingress.
- Agent-visible attachment references are explicitly labeled untrusted; binary content is not embedded. Persisted attachment name/URL values are redacted.
- Egress uses deterministic nonces, bounded transient retry, typed terminal errors, and does not send to forged targets.
- No secrets or message content were added to plugin logs.
## Verification evidence
| Command | Result |
|---|---|
| `pnpm --filter @mosaicstack/discord-plugin test` | PASS — 44 tests; v8 coverage: 92.18% statements/lines, 86.55% branches, 100% functions |
| `pnpm --filter @mosaicstack/discord-plugin typecheck` | PASS |
| `pnpm --filter @mosaicstack/types typecheck` | PASS |
| `pnpm --filter @mosaicstack/gateway typecheck` | PASS |
| `pnpm --filter @mosaicstack/gateway exec vitest run src/plugin/discord-ingress.security.spec.ts src/agent/__tests__/agent-service-ownership.test.ts` | PASS — 29 tests |
| `git diff --check` | PASS |
No unresolved critical, high, medium, or low security findings were identified in the reviewed final delta.

View File

@@ -0,0 +1,148 @@
# 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

@@ -0,0 +1,57 @@
# Scratchpad — #756 Official Discord channel plugin
- **Task / issue:** Official Discord channel plugin / #756
- **Branch:** `feat/756-official-discord-plugin`
- **Worktree:** `/home/hermes/agent-work/stack-discord-plugin`
- **Base:** `origin/main` at `49e8a54105eddf41e8e0e44603ded616ee76044f`
- **Objective:** Deliver harness-neutral Discord routing, native mention-to-thread behavior, untagged in-channel interaction, fail-closed channel/user authorization, and transport-neutral contracts for future official channel plugins.
- **Collision boundary:** Do not modify orchestrator-to-Pi migration, logical-agent lease/fencing (#754/#755), runtime provider implementations, or orchestrator-owned `docs/TASKS.md`.
- **Working budget:** 55K tokens for requirements, implementation, tests, documentation, independent review, and delivery. No user-specified hard cap. Reduce optional refactoring before reducing acceptance coverage.
## Assumptions
1. **ASSUMPTION:** Every configured Discord channel is intentionally agent-bound, so an authorized human's untagged message is agent input and receives an in-channel response. Rationale: this satisfies the requested no-tag behavior without activating the bot in arbitrary channels.
2. **ASSUMPTION:** Mentioning the bot in a parent channel creates or reuses a public Discord thread; messages already in a thread remain in that thread without repeated mentions. Rationale: Discord does not support nested threads and the request describes tags as the thread-selection signal.
3. **ASSUMPTION:** One bot process may host multiple configured channel-to-logical-agent bindings. Rationale: bindings are already configuration-owned and this avoids per-agent Discord credentials.
4. **ASSUMPTION:** Static guild/channel/user allowlists and paired-user roles remain the administration surface for this slice. Rationale: dynamic admin UI is larger and can be added without changing adapter contracts.
5. **ASSUMPTION:** The stable conversation handle contains logical agent plus Discord channel/thread identity and never a harness/provider identifier. Rationale: runtime re-enrollment and the active lease work can change Claude/Codex/Pi/OpenCode behind the same channel connection.
6. **ASSUMPTION:** Canonical documentation remains in-repository for this slice; no external publishing is performed.
## Plan
1. Update `docs/PRD.md` before code with #756 scope, constraints, assumptions, and acceptance criteria.
2. Add failing Discord behavior and authorization tests first.
3. Add transport-neutral channel DTO/contracts in `@mosaicstack/types`.
4. Implement mention-to-thread, untagged in-channel, existing-thread, stable conversation, and adapter health behavior without runtime-specific imports.
5. Update admin/developer/channel protocol docs and documentation checklist.
6. Run focused tests, typecheck, lint, formatting, full applicable baseline, and coverage.
7. Run independent code and security review; remediate and re-review.
8. Commit, queue-guard, push, open PR to `main`, wait for green CI, squash merge, verify merged CI, and close #756.
## Progress
- 2026-07-14: Loaded mission state (none active), global/project guidance, current `origin/main`, existing Discord/Tess/fleet connector architecture, issue #709 history, and active portability issues #754/#755.
- 2026-07-14: Created issue #756 through the Mosaic wrapper.
- 2026-07-14: Created isolated worktree/branch from current `origin/main`; the stale root checkout and unrelated QA artifacts remain untouched.
## TDD decision
Required and applied. This change modifies authorization-sensitive remote ingress and routing behavior. Failing permission and routing tests will be captured before implementation.
## Risks / blockers
- Active logical-agent lease work may later add stronger fencing fields. This slice must expose a clean, harness-neutral seam without duplicating that schema.
- Discord thread creation is an external side effect. Unit tests use a typed fake; live credential smoke testing is out of scope and must not use committed secrets.
- Repository-wide checks may expose unrelated baseline debt; changed-scope evidence and CI remain mandatory.
## Verification evidence
- `pnpm format:check` — passed.
- `git diff --check` — passed.
- `pnpm typecheck` — passed (42 Turbo tasks).
- `pnpm lint` — passed (23 Turbo tasks).
- `pnpm build` — passed (23 Turbo tasks).
- `pnpm --filter @mosaicstack/discord-plugin test` — passed: 44 tests; V8 coverage 92.18% statements/lines, 86.55% branches, 100% functions (all configured thresholds ≥85%).
- Focused gateway verification passed: Discord ingress/security, cross-surface, ownership, redaction/concurrency, and agent attachment tests.
- `pnpm test` — changed-scope suites passed; repository baseline remains blocked by `apps/gateway/src/__tests__/cross-user-isolation.test.ts` requiring PostgreSQL at localhost port 5433 (`ECONNREFUSED`). The failure is unrelated to this change.
- Independent code/security re-reviews requested after final remediation; reports are stored under ignored `docs/reports/` evidence paths.

View File

@@ -0,0 +1,148 @@
# 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

@@ -0,0 +1,37 @@
# 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,46 @@
# FCM-M2-002 — Generation-Guarded Fleet Agent CRUD
- **Task / issue:** FCM-M2-002 / #758
- **Branch / base:** `feat/758-fleet-agent-crud` from `origin/main` `191efaefeb5c0c6bb218c1292d12ce8e73ace12b`
- **Budget:** 30K card allocation; no deployment or live-fleet actions.
## Objective
Provide local roster-owned create, get, update, and delete mutations with a generation precondition, deterministic dry-run plan, complete-state structural/semantic/projection validation before writes, atomic roster persistence, and redacted recovery output on a late projection failure.
## Scope and exclusions
- Roster v2 is the sole desired-state authority. Reuse `parseRosterV2`, `renderRosterV2Yaml`, `validateRosterV2Semantics`, and the generated-environment boundary.
- Fresh create defaults to `enabled: true` and `desired_state: stopped`; this card never starts a runtime.
- Excluded: reconcile/apply; lifecycle/session/systemd/tmux actions; migration/canary; remote/connector/gateway mutation; arbitrary commands/channels/secrets; generated files as authority; `docs/TASKS.md` and orchestration ledger changes.
## Red-first plan
1. Add failing tests for dry-run non-mutation, stale generation, concurrent writer locking, stopped default create, equivalent idempotency, complete proposed-state semantic/boundary validation, atomic roster write, and injected late projection failure with redacted recovery details.
2. Implement only a roster-v2 CRUD service and file adapter; no legacy `fleet add/remove` behavior expansion.
3. Add operator/reference docs for JSON outcomes, generation retries, recovery, and no-runtime-action boundary.
## Progress
- Preflight: clean exact base and no duplicate PR confirmed.
- Intake read: FCM PRD/AC-FCM-03, task row, launch and generated-env boundaries, roster v2/resolver contracts, legacy fleet command behavior, delivery/QA/TypeScript/security/documentation guidance.
- TDD: RED observed for the missing CRUD module. GREEN: focused suite passes 7 tests covering stopped-default create, stale generation, idempotency, dry-run non-mutation, concurrent lock denial, exact stale/absent generated-projection delete cleanup, and redacted late-projection recovery.
- REVIEW-1 remediation: RED observed for absent CLI create/get/update/delete/plan wiring. Added roster-v2-only JSON commands, including safe `get`, read-only planning/dry-run, explicit persisted-start recording (never runtime start), stable handled error codes, and focused CLI coverage.
- REVIEW-2 remediation: RED observed for missing direct fleet-control-plane registration and ambiguous partial late-I/O result. The public surface is now direct `mosaic fleet {create,get,update,delete,plan}` (not root gateway `mosaic agent`); a late filesystem projection failure returns non-zero redacted JSON with `authoritativeRoster: committed` and `projections: incomplete`, proving no rollback/no-op claim.
- REVIEW-3 remediation: RED observed for unnamed update/delete plans and deleted-agent quarantine conflict. `plan <operation> [name]` now requires target names only for update/delete; delete validates/removes only exact generated state while retaining local/legacy/quarantine/unrelated files. Actual CLI/filesystem tests cover create/update/delete plans, plan validation/non-mutation, retained artifacts/dry-run bytes, unsafe permission/symlink rejection, and post-roster delete recovery. Added the required operator how-to.
- REVIEW-4 remediation: RED observed that actual Commander create accepted and silently dropped disallowed `command`, `channel`, and `secretRef` keys. The `--agent` object and nested `launch` now use strict own-property allowlists; non-plain/prototype-sensitive shapes and unknown keys fail `invalid-request` before resolver, roster, or projection mutation. Actual Commander plan/create/update tests cover top-level command/channel/secretRef/constructor/prototype/`__proto__` shapes and nested launch unknown fields, byte-identical retained artifacts, non-zero exit, and diagnostics that never echo rejected values. Docs state the same boundary.
## Risks / assumptions
- **ASSUMPTION:** M2 mutations operate exclusively on the existing v2 roster contract because generation/lifecycle fields are v2-only; legacy v1 add/remove commands remain unchanged compatibility paths.
- A multi-file roster/projection write cannot be one filesystem rename. The roster is authoritative; a post-roster projection failure returns a redacted recovery plan naming only safe paths/actions, never environment values.
## Verification evidence
- `pnpm --filter @mosaicstack/mosaic test -- fleet-agent-crud-command.spec.ts fleet-agent-crud.spec.ts generated-env-boundary.spec.ts` — PASS (48 tests after REVIEW-4 remediation).
- `pnpm --filter @mosaicstack/mosaic test` — PASS (54 files, 794 tests after REVIEW-4 remediation).
- `pnpm --filter @mosaicstack/mosaic lint` — PASS.
- `pnpm --filter @mosaicstack/mosaic typecheck` — PASS.
- `pnpm format:check` and `git diff --check` — PASS.
- Root `pnpm typecheck`, `pnpm lint`, `pnpm format:check`, and `git diff --check` — PASS after REVIEW-4 remediation; full package test is 54 files / 794 tests.
- REVIEW-4 remediation focused checks are green; fresh full-delta independent review is required before author-green. No commit, push, or PR opened.

View File

@@ -0,0 +1,110 @@
# 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

@@ -1,3 +1,28 @@
# Tess Plugin Authoring
Plugins are replaceable adapters. Declare capabilities, derive scope from trusted context, preserve correlation IDs, redact before persistence/egress, and return unsupported operations as fail-closed results. Names and identities are configuration data, not literals in keys or defaults.
## Official channel adapter contract
Official Discord, Matrix, Slack, and future channel adapters share contracts exported from `@mosaicstack/types` under `channel/`:
- `OfficialChannelAdapter` provides `name`, `start()`, `stop()`, and non-throwing connection `health()`.
- `ChannelMessageDto` and `ChannelAttachmentDto` normalize transport data with JSON-safe metadata.
- `ChannelBindingDto` and `ChannelAuthorizedPrincipalDto` normalize configuration-owned logical-agent binding and the already-allowlisted/paired external actor.
- `ChannelIngressDto` carries operation, correlation, native message ID, authorized principal, normalized message, and stable route into `ChannelIngressPort`.
- `ChannelConversationRouteDto` binds a configured channel to `logicalAgentId`, stable `conversationId`, authorization parent, and response target.
- `ChannelEgressDto` and `ChannelEgressPort` separate where a response is delivered from the gateway's runtime/provider selection.
`ChannelConversationRouteDto` deliberately has no harness, provider, model, process, or native runtime-session field. The gateway owns runtime selection, durable enrollment, authorization, audit, and lease/fencing. A channel adapter must not call Claude, Codex, Pi, OpenCode, tmux, or Matrix runtime providers directly. Discord currently preserves its signed Socket.IO compatibility ingress for established gateway authentication/replay/approval controls while normalizing the same ingress DTO; supplied direct ports are the future registration path.
## Adapter requirements
1. Resolve configuration-owned channel and logical-agent bindings before dispatch. A binding may carry a trusted gateway agent-config reference, but the stable route contains only the logical agent; gateway verifies the reference resolves to that agent before runtime selection.
2. Apply channel-native allowlists and paired-user roles before any external side effect such as thread creation.
3. Preserve native message ID, correlation ID, channel/thread address, attachments, and response target.
4. Treat normal channel parents (for example Discord categories) separately from thread parents.
5. Keep reconnect and conversation identity independent of the active runtime provider.
6. Report sanitized connection/routing failures without message bodies or credentials.
7. Pass the shared route/authorization contract suite plus adapter-specific translation tests.
Discord establishes the first policy: authorized untagged messages respond in the configured channel; a mention creates a thread or reuses the thread already attached to that message; existing thread messages stay there. Runtime control commands remain on the current durable session. Matrix and Slack should translate native rooms/threads into the same route and response-target semantics rather than adding transport branches to gateway core.

View File

@@ -43,3 +43,4 @@
| 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,5 +1,13 @@
# Tess User Guide
## Discord conversations
In a configured Tess/interaction channel, an authorized untagged message is sent to the bound logical agent and its response appears in the channel. Mention the bot when starting a separate topic: Mosaic reuses a thread already attached to that same Discord message, or creates a new thread for the message, and responds there. Continue in that thread without tagging the bot again. Messages from unconfigured channels or users without an authorized pairing are ignored without creating a thread.
`/approve` and `/stop <approval>` operate on the current channel/thread session and do not open a new thread. The Discord connection is bound to the logical agent conversation, not Claude, Codex, Pi, OpenCode, or another harness; a runtime handoff behind Mosaic does not change where you continue the conversation.
## CLI and HTTP interaction
All HTTP interaction calls require authenticated session credentials and `X-Correlation-Id`. Use `GET /api/interaction/{agentName}/sessions?provider=...` to list only visible runtime sessions, then enroll with `POST .../sessions/{sessionId}/enroll` body `{providerId,runtimeSessionId}`. Attach uses `{mode:"read"}`; send uses `{content,idempotencyKey}`. Stop requires `{approvalRef}` and fails with 403 without the exact durable approval. Recovery only requeues interrupted durable work.
Memory is user-scoped: preferences support list/get/upsert/delete; insights support list/get/create/delete; search body is `{query,limit?,maxDistance?}`. Mos work is handed off with `POST /api/coord/mos/handoff`; observe and result use the returned handoff ID.

View File

@@ -28,6 +28,7 @@ export default tseslint.config(
'apps/web/e2e/helpers/*.ts',
'apps/web/playwright.config.ts',
'apps/gateway/vitest.config.ts',
'plugins/discord/vitest.config.ts',
'packages/db/vitest.config.ts',
'packages/storage/vitest.config.ts',
'packages/mosaic/vitest.config.ts',

View File

@@ -0,0 +1,261 @@
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

@@ -0,0 +1,381 @@
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,3 +5,4 @@ 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

@@ -0,0 +1,36 @@
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,6 +113,13 @@
"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,6 +15,7 @@ import {
uniqueIndex,
real,
integer,
bigint,
customType,
} from 'drizzle-orm/pg-core';
@@ -487,6 +488,68 @@ 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,7 +9,8 @@ package, normally at:
```
The default tmux socket is `mosaic-fleet` so fleet commands do not touch the
default tmux server.
default tmux server. The roster is the desired-state authority; generated environment files are
rebuildable projections, never a second source of configuration.
## Examples
@@ -31,6 +32,17 @@ 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,19 +12,22 @@ 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 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.
> 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.
## 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 |
@@ -33,6 +36,7 @@ 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

@@ -0,0 +1,16 @@
# 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

@@ -0,0 +1,16 @@
# 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

@@ -0,0 +1,16 @@
# 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,45 +24,56 @@ The agent template calls:
which starts or reuses a tmux session on `MOSAIC_TMUX_SOCKET`.
## Local customization
## Generated environment and local data
Per-agent overrides live outside the package in:
The roster-derived projection is written outside the package at:
```text
~/.config/mosaic/fleet/agents/<agent>.env
~/.config/mosaic/fleet/agents/<agent>.env.generated
```
Example:
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:
```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
```text
~/.config/mosaic/fleet/agents/<agent>.env.local
```
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
```bash
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
systemctl --user start mosaic-tmux-holder.service
systemctl --user start mosaic-agent@canary.service
tmux -L mosaic-fleet ls
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.
# For an operator-interaction service, the roster/env identity selects the
# generic unit instance; no service source is renamed for an instance.
```bash
# Create a site-owned canary roster. Inspect an existing roster before using --force.
mosaic fleet init --profile minimal --write
mosaic fleet install
systemctl --user daemon-reload
mosaic fleet start canary-pi
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
systemctl --user start mosaic-interaction-agent@<agent-name>.service
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
~/.config/mosaic/tools/fleet/print-interaction-effective-policy.sh <agent-name>
```
Do not use `tmux kill-server` without `-L mosaic-fleet`; this pattern is meant

View File

@@ -7,16 +7,13 @@ 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
# 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'
# 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
[Install]
WantedBy=default.target

View File

@@ -7,11 +7,13 @@ 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
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'
# 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
[Install]
WantedBy=default.target

View File

@@ -6,10 +6,11 @@ After=default.target
[Service]
Type=oneshot
RemainAfterExit=yes
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'
# 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'
[Install]
WantedBy=default.target

View File

@@ -5,6 +5,8 @@ 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
@@ -14,16 +16,40 @@ 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"
grep -qF 'kill-session -t "=%i"' "$AGENT" || fail "agent stop does not exact-match its session"
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 'Requires=mosaic-tmux-holder.service' "$INTERACTION" || fail "interaction service does not require holder"
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"
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"
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 || {
@@ -32,4 +58,102 @@ 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,39 +1,207 @@
#!/usr/bin/env bash
set -euo pipefail
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}
# 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.
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
MODE=launch
case "${1:-}" in
--stop)
MODE=stop
AGENT_NAME=${2:-}
;;
--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" "$@"
@@ -42,140 +210,90 @@ _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
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
# 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
# ── 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")
candidates+=("$HOME/.npm-global/bin")
candidates+=("$HOME/.local/bin")
local prefix=""
local prefix="" dir
for dir in "${candidates[@]}"; do
[ -d "$dir" ] || continue
if [ -z "$prefix" ]; then
prefix="$dir"
else
case ":${prefix}:" in
*":${dir}:"*) ;; # already in our prefix — skip
*) prefix="${prefix}:${dir}" ;;
esac
fi
case ":${prefix}:" in *":${dir}:"*) ;; *) prefix="${prefix:+$prefix:}$dir" ;; esac
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"
# 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 resolved
resolved=$(cd "$workdir" 2>/dev/null && pwd -P) || resolved="$workdir"
local claude_json="${MOSAIC_CLAUDE_JSON:-${CLAUDE_CONFIG_DIR:+$CLAUDE_CONFIG_DIR/.claude.json}}"
claude_json="${claude_json:-$HOME/.claude.json}"
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'
command -v python3 >/dev/null 2>&1 || return 1
MOSAIC_CJ="$claude_json" MOSAIC_TRUST_DIR="$resolved" python3 - <<'PY'
import json, os, sys, tempfile
cj = os.environ["MOSAIC_CJ"]
d = os.environ["MOSAIC_TRUST_DIR"]
@@ -184,22 +302,19 @@ 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) # atomic
os.replace(tmp, cj)
except Exception:
try:
os.unlink(tmp)
@@ -208,55 +323,55 @@ except Exception:
sys.exit(3)
PY
}
if command -v flock >/dev/null 2>&1; then
( flock 9; _seed ) 9>"$lock" 2>/dev/null || _seed
else
_seed
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
# ── Launch the tmux session (no exec — we continue to wire the heartbeat) ────
# 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"
_tmux new-session -d -s "$AGENT_NAME" -c "$MOSAIC_AGENT_WORKDIR" \
bash -c "$PANE_SHELL_SNIPPET"
"${LAUNCH_ENV[@]}" "${LAUNCH_COMMAND[@]}"
# ── 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"
local pane_pid="$2"
local run_dir="$3"
local interval="$4"
local agent="$1" pane_pid="$2" run_dir="$3" 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; 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=%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_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
@@ -266,7 +381,6 @@ _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,11 +9,7 @@ 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"
exec "$(cd -- "$(dirname -- "$0")" && pwd)/start-agent-session.sh" "$AGENT_NAME"
# 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"

View File

@@ -0,0 +1,64 @@
#!/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,373 +3,410 @@ set -euo pipefail
SCRIPT_DIR=$(cd -- "$(dirname -- "$0")" && pwd)
START="$SCRIPT_DIR/start-agent-session.sh"
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
}
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
fail() {
echo "FAIL: $*" >&2
exit 1
}
# ── 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
cat > "$FAKE_BIN/tmux" <<'SHIM'
#!/usr/bin/env bash
# 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"
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
if [ "\$subcmd" = "list-panes" ]; then
# Return empty: no sidecar spawned (heartbeat is not the focus of this test).
echo ""
exit 0
done
fi
exit 0
;;
*) exit 0 ;;
esac
SHIM
chmod +x "$FAKE_BIN/tmux"
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
cat > "$FAKE_BIN/mosaic" <<'SHIM'
#!/usr/bin/env bash
subcmd="\$3"
if [ "\$subcmd" = "has-session" ]; then exit 1; fi
if [ "\$subcmd" = "new-session" ]; then
printf '%s\n' "\$@" > "$TMUX_ARGS_FILE2"
exit 0
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"
fi
if [ "\$subcmd" = "list-panes" ]; then
# Return empty: no sidecar spawned (heartbeat is not the focus of this test).
echo ""
exit 0
# 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"
fi
exit 0
SHIM2
chmod +x "$FAKE_BIN2/tmux"
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before unsafe parent rejection"
echo "$output" | grep -qF 'code=unsafe-permissions' || fail "unsafe parent diagnostic missing"
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
: > "$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"
fi
if [ "\$subcmd" = "list-panes" ]; then
# Return empty: no sidecar spawned (heartbeat is not the focus of this test).
echo ""
exit 0
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before symlinked parent rejection"
echo "$output" | grep -qF 'code=unsafe-directory' || fail "symlinked parent diagnostic missing"
# 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"
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
if [ "$hazard" = symlink ]; then
local target="${node}-target"
mv "$node" "$target"
ln -s "$target" "$node"
else
chmod 777 "$node"
fi
exit 0
SHIM5
chmod +x "$FAKE_BIN5/tmux"
SOCKET5="mosaic-agent-test5-$RANDOM-$$"
AGENT5="agent5-$RANDOM"
WORKDIR5=$(mktemp -d)
CLEANUP_DIRS+=("$WORKDIR5")
CLEANUP_SOCKETS+=("$SOCKET5")
: > "$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
}
# 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"
all_args5=$(cat "$TMUX_ARGS_FILE5" 2>/dev/null || true)
rm -f "$TMUX_ARGS_FILE5"
rm -rf "$WORKDIR5"
echo "--- test 5: launcher-PATH candidate must still appear in pane export ---"
echo "$all_args5"
echo "--- end test 5 args ---"
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))
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
done
[ -f "$HB_FILE" ] || fail "test6: heartbeat file not written at $HB_FILE within 5s"
# 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
hb_content=$(cat "$HB_FILE")
echo "--- test 6: heartbeat file content ---"
echo "$hb_content"
echo "--- end test 6 ---"
# 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
# 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"
# 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"
# ── 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.
# 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
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")
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
AGENT7="my-fleet-agent-$RANDOM"
INTERVAL7="42"
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"
}
# 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"
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 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"
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"
}
SOCKET7="mosaic-agent-test7-$RANDOM-$$"
WORKDIR7=$(mktemp -d)
CLEANUP_DIRS+=("$WORKDIR7")
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"
}
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"
# 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"
# Give the background setsid shim a moment to finish writing the capture file.
sleep 0.5
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"
setsid_args=$(cat "$SETSID_ARGS_FILE" 2>/dev/null | tr '\0' '\n' || true)
rm -f "$SETSID_ARGS_FILE"
rm -rf "$WORKDIR7"
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"
echo "--- test 7: captured setsid args ---"
echo "$setsid_args"
echo "--- end test 7 ---"
# 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"
# 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)"
# 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 must use the configured interval.
echo "$setsid_args" | grep -qF "$INTERVAL7" || \
fail "test7: sidecar script does not reference configured interval ($INTERVAL7)"
# 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
echo "ok - start-agent-session"
echo 'ok - start-agent-session generated environment boundary'

View File

@@ -0,0 +1,657 @@
import { chmod, mkdir, mkdtemp, readFile, rename, rm, symlink, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Command } from 'commander';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { fleetAgentMutationExitCode } from './fleet-agent-crud-command.js';
import { registerFleetCommand, type FleetCommandDeps } from './fleet.js';
const roster = `
version: 2
generation: 7
transport: tmux
tmux:
socket_name: mosaic-fleet
holder_session: _holder
defaults:
working_directory: /srv/mosaic
runtime: pi
runtimes:
pi:
reset_command: /new
agents:
- name: orchestrator
alias: Orchestrator
class: orchestrator
runtime: pi
provider: openai
model: gpt-5.6-sol
reasoning: high
tool_policy: orchestrator
working_directory: /srv/mosaic
persistent_persona: true
reset_between_tasks: false
lifecycle:
enabled: true
desired_state: stopped
launch:
yolo: true
`;
const agent = {
name: 'coder0',
alias: 'Coder 0',
className: 'code',
runtime: 'pi',
provider: 'openai',
model: 'gpt-5.6-sol',
reasoning: 'high',
toolPolicy: 'code',
workingDirectory: '/srv/mosaic',
persistentPersona: false,
resetBetweenTasks: true,
launch: { yolo: true },
};
let cleanup: string | undefined;
afterEach(async (): Promise<void> => {
vi.restoreAllMocks();
process.exitCode = undefined;
if (cleanup) await rm(cleanup, { recursive: true, force: true });
cleanup = undefined;
});
async function fleetHome(): Promise<string> {
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-fleet-crud-command-'));
const fleetDir = join(cleanup, 'fleet');
const agentsDir = join(fleetDir, 'agents');
const rolesDir = join(fleetDir, 'roles');
await mkdir(agentsDir, { recursive: true, mode: 0o700 });
await mkdir(rolesDir, { recursive: true, mode: 0o700 });
await chmod(cleanup, 0o700);
await chmod(fleetDir, 0o700);
await chmod(agentsDir, 0o700);
await chmod(rolesDir, 0o700);
await writeFile(join(fleetDir, 'roster.yaml'), roster, { mode: 0o600 });
for (const className of ['orchestrator', 'code']) {
await writeFile(
join(rolesDir, `${className}.md`),
`# ${className}\n\n(\`class: ${className}\`)\n`,
{
mode: 0o600,
},
);
}
return cleanup;
}
function program(mosaicHome: string, deps: FleetCommandDeps = {}): Command {
const result = new Command();
result.exitOverride();
registerFleetCommand(result, { ...deps, mosaicHome });
return result;
}
function output(): { lines: string[] } {
const lines: string[] = [];
vi.spyOn(console, 'log').mockImplementation((value: string): void => {
lines.push(value);
});
return { lines };
}
describe('mosaic fleet local roster mutations', (): void => {
it('returns non-zero when a projection failure leaves recovery work after roster persistence', (): void => {
expect(
fleetAgentMutationExitCode({
recovery: {
code: 'projection-apply-failed',
rosterPath: '/private/fleet/roster.yaml',
action: 'regenerate-projections-from-roster',
},
}),
).toBe(1);
});
it('returns zero for a mutation result without recovery work', (): void => {
expect(fleetAgentMutationExitCode({})).toBe(0);
});
it('registers programmatic local create, get, update, delete, and plan commands', async (): Promise<void> => {
const home = await fleetHome();
const root = program(home);
const fleet = root.commands.find((candidate: Command): boolean => candidate.name() === 'fleet');
expect(fleet?.commands.map((candidate: Command): string => candidate.name())).toEqual(
expect.arrayContaining(['create', 'delete', 'get', 'plan', 'update']),
);
});
it('runs the JSON mutation commands through the direct mosaic fleet control plane', async (): Promise<void> => {
const home = await fleetHome();
const root = new Command();
root.exitOverride();
registerFleetCommand(root, { mosaicHome: home });
const fleet = root.commands.find((candidate: Command): boolean => candidate.name() === 'fleet');
expect(fleet?.commands.map((candidate: Command): string => candidate.name())).toEqual(
expect.arrayContaining(['create', 'delete', 'get', 'plan', 'update']),
);
});
it('gets one v2 roster agent as stable JSON without mutating the roster', async (): Promise<void> => {
const home = await fleetHome();
const rosterPath = join(home, 'fleet', 'roster.yaml');
const captured = output();
const root = new Command();
root.exitOverride();
registerFleetCommand(root, { mosaicHome: home });
await root.parseAsync(['node', 'mosaic', 'fleet', 'get', 'orchestrator']);
expect(JSON.parse(captured.lines.join('\n'))).toMatchObject({
generation: 7,
agent: { name: 'orchestrator', lifecycle: { desiredState: 'stopped' } },
});
expect(await readFile(rosterPath, 'utf8')).toBe(roster);
});
it('returns a JSON error and non-zero exit for a missing local agent', async (): Promise<void> => {
const home = await fleetHome();
const captured = output();
await program(home).parseAsync(['node', 'mosaic', 'fleet', 'get', 'missing']);
expect(JSON.parse(captured.lines.join('\n'))).toEqual({ error: { code: 'agent-not-found' } });
expect(process.exitCode).toBe(1);
});
it('prints a deterministic create plan without changing roster or generated projections', async (): Promise<void> => {
const home = await fleetHome();
const rosterPath = join(home, 'fleet', 'roster.yaml');
const captured = output();
await program(home).parseAsync([
'node',
'mosaic',
'fleet',
'plan',
'create',
'--expected-generation',
'7',
'--agent',
JSON.stringify(agent),
]);
expect(JSON.parse(captured.lines.join('\n'))).toMatchObject({
applied: false,
plan: {
operation: 'create',
currentGeneration: 7,
nextGeneration: 8,
agent: { name: 'coder0', lifecycle: { enabled: true, desiredState: 'stopped' } },
},
});
expect(await readFile(rosterPath, 'utf8')).toBe(roster);
await expect(
readFile(join(home, 'fleet', 'agents', 'coder0.env.generated'), 'utf8'),
).rejects.toThrow();
});
it('plans create, update, and delete with operation-specific target names without mutation', async (): Promise<void> => {
const home = await fleetHome();
const rosterPath = join(home, 'fleet', 'roster.yaml');
const captured = output();
const updatedCoder = { ...agent, alias: 'Coder Updated' };
await program(home).parseAsync([
'node',
'mosaic',
'fleet',
'plan',
'create',
'--expected-generation',
'7',
'--agent',
JSON.stringify(agent),
]);
await program(home).parseAsync([
'node',
'mosaic',
'fleet',
'create',
'--expected-generation',
'7',
'--agent',
JSON.stringify(agent),
]);
const beforePlans = await readFile(rosterPath, 'utf8');
const beforeOrchestratorProjection = await readFile(
join(home, 'fleet', 'agents', 'orchestrator.env.generated'),
'utf8',
);
captured.lines.length = 0;
await program(home).parseAsync([
'node',
'mosaic',
'fleet',
'plan',
'update',
'coder0',
'--expected-generation',
'8',
'--agent',
JSON.stringify(updatedCoder),
]);
await program(home).parseAsync([
'node',
'mosaic',
'fleet',
'plan',
'delete',
'coder0',
'--expected-generation',
'8',
]);
const plans = captured.lines.map((line: string): unknown => JSON.parse(line));
expect(plans).toMatchObject([
{ plan: { operation: 'update', agent: { name: 'coder0' } } },
{ plan: { operation: 'delete', agent: { name: 'coder0' } } },
]);
expect(await readFile(rosterPath, 'utf8')).toBe(beforePlans);
expect(
await readFile(join(home, 'fleet', 'agents', 'orchestrator.env.generated'), 'utf8'),
).toBe(beforeOrchestratorProjection);
});
it.each([
['plan create', ['fleet', 'plan', 'create'], 'top-level'],
['create', ['fleet', 'create'], 'top-level'],
['update', ['fleet', 'update', 'orchestrator'], 'top-level'],
['plan create', ['fleet', 'plan', 'create'], 'launch'],
['create', ['fleet', 'create'], 'launch'],
['update', ['fleet', 'update', 'orchestrator'], 'launch'],
])(
'rejects unknown and prototype-sensitive agent fields without mutation',
async (_operation: string, command: string[], shape: string): Promise<void> => {
const home = await fleetHome();
const rosterPath = join(home, 'fleet', 'roster.yaml');
const agentsDir = join(home, 'fleet', 'agents');
const captured = output();
const artifactContents = new Map([
['orchestrator.env.generated', 'generated-before\n'],
['orchestrator.env.local', 'local-before\n'],
['orchestrator.env', 'legacy-before\n'],
['orchestrator.env.quarantine', 'quarantine-before\n'],
]);
for (const [filename, content] of artifactContents) {
await writeFile(join(agentsDir, filename), content, { mode: 0o600 });
}
const rejectedValue = 'must-not-appear-in-diagnostics';
const unsafeAgent = {
...agent,
name: 'orchestrator',
launch: shape === 'launch' ? { yolo: true, command: rejectedValue } : { yolo: true },
...(shape === 'top-level'
? {
command: rejectedValue,
channel: rejectedValue,
secretRef: rejectedValue,
constructor: rejectedValue,
prototype: rejectedValue,
}
: {}),
};
const payload =
shape === 'top-level'
? JSON.stringify(unsafeAgent).replace(/}$/, ',"__proto__":{"unsupported":true}}')
: JSON.stringify(unsafeAgent);
await program(home).parseAsync([
'node',
'mosaic',
...command,
'--expected-generation',
'7',
'--agent',
payload,
]);
const diagnostic = captured.lines.pop() ?? '';
expect(JSON.parse(diagnostic)).toEqual({ error: { code: 'invalid-request' } });
expect(diagnostic).not.toContain(rejectedValue);
expect(process.exitCode).toBe(1);
expect(await readFile(rosterPath, 'utf8')).toBe(roster);
for (const [filename, content] of artifactContents) {
expect(await readFile(join(agentsDir, filename), 'utf8')).toBe(content);
}
},
);
it('rejects missing update/delete plan names and an extra create plan name', async (): Promise<void> => {
const home = await fleetHome();
const captured = output();
await program(home).parseAsync([
'node',
'mosaic',
'fleet',
'plan',
'delete',
'--expected-generation',
'7',
]);
expect(JSON.parse(captured.lines.pop() ?? '')).toEqual({ error: { code: 'invalid-request' } });
await program(home).parseAsync([
'node',
'mosaic',
'fleet',
'plan',
'create',
'coder0',
'--expected-generation',
'7',
'--agent',
JSON.stringify(agent),
]);
expect(JSON.parse(captured.lines.pop() ?? '')).toEqual({ error: { code: 'invalid-request' } });
});
it('plans persisted start as desired state without starting a runtime', async (): Promise<void> => {
const home = await fleetHome();
const captured = output();
await program(home).parseAsync([
'node',
'mosaic',
'fleet',
'plan',
'create',
'--expected-generation',
'7',
'--agent',
JSON.stringify(agent),
'--persisted-start',
]);
expect(JSON.parse(captured.lines.join('\n'))).toMatchObject({
applied: false,
plan: { agent: { lifecycle: { desiredState: 'running' } } },
});
});
it('returns non-zero JSON when a filesystem projection fails after roster persistence', async (): Promise<void> => {
const home = await fleetHome();
const rosterPath = join(home, 'fleet', 'roster.yaml');
const agentsDir = join(home, 'fleet', 'agents');
const captured = output();
await program(home, {
projectionApplier: async (): Promise<never> => {
throw new Error('injected projection failure');
},
}).parseAsync([
'node',
'mosaic',
'fleet',
'create',
'--expected-generation',
'7',
'--agent',
JSON.stringify(agent),
]);
expect(JSON.parse(captured.lines.join('\n'))).toMatchObject({
applied: false,
authoritativeRoster: 'committed',
projections: 'incomplete',
recovery: { code: 'projection-apply-failed', action: 'regenerate-projections-from-roster' },
});
expect(process.exitCode).toBe(1);
expect(await readFile(rosterPath, 'utf8')).toContain('generation: 8');
expect(await readFile(rosterPath, 'utf8')).toContain('name: coder0');
await expect(readFile(join(agentsDir, 'coder0.env.generated'), 'utf8')).rejects.toThrow();
});
it('deletes only the target generated projection while retaining legacy and quarantine artifacts', async (): Promise<void> => {
const home = await fleetHome();
const rosterPath = join(home, 'fleet', 'roster.yaml');
const agentsDir = join(home, 'fleet', 'agents');
const captured = output();
await program(home).parseAsync([
'node',
'mosaic',
'fleet',
'create',
'--expected-generation',
'7',
'--agent',
JSON.stringify(agent),
]);
await writeFile(join(agentsDir, 'coder0.env.local'), 'MOSAIC_RUNTIME_BIN=/usr/bin/pi\n', {
mode: 0o600,
});
await writeFile(join(agentsDir, 'coder0.env'), 'MOSAIC_AGENT_COMMAND=legacy-command\n', {
mode: 0o600,
});
await writeFile(join(agentsDir, 'coder0.env.quarantine'), 'quarantine-before\n', {
mode: 0o600,
});
await writeFile(join(agentsDir, 'unrelated.env.generated'), 'unrelated-before\n', {
mode: 0o600,
});
const beforeDryRun = await Promise.all([
readFile(rosterPath, 'utf8'),
readFile(join(agentsDir, 'coder0.env.generated'), 'utf8'),
readFile(join(agentsDir, 'coder0.env.local'), 'utf8'),
readFile(join(agentsDir, 'coder0.env'), 'utf8'),
readFile(join(agentsDir, 'coder0.env.quarantine'), 'utf8'),
readFile(join(agentsDir, 'unrelated.env.generated'), 'utf8'),
]);
await program(home).parseAsync([
'node',
'mosaic',
'fleet',
'delete',
'coder0',
'--expected-generation',
'8',
'--dry-run',
]);
expect(JSON.parse(captured.lines.pop() ?? '')).toMatchObject({
applied: false,
authoritativeRoster: 'unchanged',
projections: 'not-applied',
});
expect(
await Promise.all([
readFile(rosterPath, 'utf8'),
readFile(join(agentsDir, 'coder0.env.generated'), 'utf8'),
readFile(join(agentsDir, 'coder0.env.local'), 'utf8'),
readFile(join(agentsDir, 'coder0.env'), 'utf8'),
readFile(join(agentsDir, 'coder0.env.quarantine'), 'utf8'),
readFile(join(agentsDir, 'unrelated.env.generated'), 'utf8'),
]),
).toEqual(beforeDryRun);
await program(home).parseAsync([
'node',
'mosaic',
'fleet',
'delete',
'coder0',
'--expected-generation',
'8',
]);
expect(JSON.parse(captured.lines.pop() ?? '')).toMatchObject({
applied: true,
authoritativeRoster: 'committed',
projections: 'complete',
});
await expect(readFile(join(agentsDir, 'coder0.env.generated'), 'utf8')).rejects.toThrow();
expect(await readFile(join(agentsDir, 'coder0.env.local'), 'utf8')).toBe(beforeDryRun[2]);
expect(await readFile(join(agentsDir, 'coder0.env'), 'utf8')).toBe(beforeDryRun[3]);
expect(await readFile(join(agentsDir, 'coder0.env.quarantine'), 'utf8')).toBe(beforeDryRun[4]);
expect(await readFile(join(agentsDir, 'unrelated.env.generated'), 'utf8')).toBe(
beforeDryRun[5],
);
expect(await readFile(rosterPath, 'utf8')).toContain('generation: 9');
});
it('reports truthful partial recovery when a delete projection fails after roster persistence', async (): Promise<void> => {
const home = await fleetHome();
const rosterPath = join(home, 'fleet', 'roster.yaml');
const agentsDir = join(home, 'fleet', 'agents');
const captured = output();
await program(home).parseAsync([
'node',
'mosaic',
'fleet',
'create',
'--expected-generation',
'7',
'--agent',
JSON.stringify(agent),
]);
captured.lines.length = 0;
await program(home, {
projectionApplier: async (): Promise<never> => {
throw new Error('injected projection failure');
},
}).parseAsync(['node', 'mosaic', 'fleet', 'delete', 'coder0', '--expected-generation', '8']);
expect(JSON.parse(captured.lines.pop() ?? '')).toMatchObject({
applied: false,
authoritativeRoster: 'committed',
projections: 'incomplete',
recovery: { code: 'projection-apply-failed', action: 'regenerate-projections-from-roster' },
});
expect(process.exitCode).toBe(1);
expect(await readFile(rosterPath, 'utf8')).toContain('generation: 9');
expect(await readFile(rosterPath, 'utf8')).not.toContain('name: coder0');
expect(await readFile(join(agentsDir, 'coder0.env.generated'), 'utf8')).toContain(
'MOSAIC_AGENT_NAME=coder0',
);
});
it.each(['unsafe directory permissions', 'symlinked directory'] as const)(
'rejects delete before roster mutation for %s',
async (hazard: 'unsafe directory permissions' | 'symlinked directory'): Promise<void> => {
const home = await fleetHome();
const rosterPath = join(home, 'fleet', 'roster.yaml');
const agentsDir = join(home, 'fleet', 'agents');
const captured = output();
await program(home).parseAsync([
'node',
'mosaic',
'fleet',
'create',
'--expected-generation',
'7',
'--agent',
JSON.stringify(agent),
]);
const beforeRoster = await readFile(rosterPath, 'utf8');
let generatedPath = join(agentsDir, 'coder0.env.generated');
if (hazard === 'unsafe directory permissions') {
await chmod(agentsDir, 0o777);
} else {
const targetDir = join(home, 'fleet', 'agents-target');
await rename(agentsDir, targetDir);
await symlink(targetDir, agentsDir, 'dir');
generatedPath = join(targetDir, 'coder0.env.generated');
}
try {
captured.lines.length = 0;
await program(home).parseAsync([
'node',
'mosaic',
'fleet',
'delete',
'coder0',
'--expected-generation',
'8',
]);
} finally {
if (hazard === 'unsafe directory permissions') await chmod(agentsDir, 0o700);
}
expect(JSON.parse(captured.lines.pop() ?? '')).toEqual({
error: { code: 'mutation-failed' },
});
expect(await readFile(rosterPath, 'utf8')).toBe(beforeRoster);
expect(await readFile(generatedPath, 'utf8')).toContain('MOSAIC_AGENT_NAME=coder0');
},
);
it('creates, updates, and deletes through the v2 roster without runtime actions', async (): Promise<void> => {
const home = await fleetHome();
const rosterPath = join(home, 'fleet', 'roster.yaml');
const agentsDir = join(home, 'fleet', 'agents');
const captured = output();
const updated = { ...agent, alias: 'Coder Zero' };
await program(home).parseAsync([
'node',
'mosaic',
'fleet',
'create',
'--expected-generation',
'7',
'--agent',
JSON.stringify(agent),
]);
expect(JSON.parse(captured.lines.pop() ?? '')).toMatchObject({ applied: true });
expect(await readFile(join(agentsDir, 'coder0.env.generated'), 'utf8')).toContain(
'MOSAIC_AGENT_NAME=coder0',
);
await program(home).parseAsync([
'node',
'mosaic',
'fleet',
'update',
'coder0',
'--expected-generation',
'8',
'--agent',
JSON.stringify(updated),
]);
expect(JSON.parse(captured.lines.pop() ?? '')).toMatchObject({
applied: true,
plan: { nextGeneration: 9, agent: { alias: 'Coder Zero' } },
});
await writeFile(join(agentsDir, 'coder0.env.local'), 'MOSAIC_RUNTIME_BIN=/usr/bin/retain\n', {
mode: 0o600,
});
await program(home).parseAsync([
'node',
'mosaic',
'fleet',
'delete',
'coder0',
'--expected-generation',
'9',
]);
expect(JSON.parse(captured.lines.pop() ?? '')).toMatchObject({
applied: true,
plan: { operation: 'delete', nextGeneration: 10, agent: { name: 'coder0' } },
});
await expect(readFile(join(agentsDir, 'coder0.env.generated'), 'utf8')).rejects.toThrow();
expect(await readFile(join(agentsDir, 'coder0.env.local'), 'utf8')).toBe(
'MOSAIC_RUNTIME_BIN=/usr/bin/retain\n',
);
expect(await readFile(rosterPath, 'utf8')).toContain('generation: 10');
expect(await readFile(rosterPath, 'utf8')).not.toContain('name: coder0');
});
});

View File

@@ -0,0 +1,392 @@
import { readFile } from 'node:fs/promises';
import { join, resolve } from 'node:path';
import type { Command } from 'commander';
import {
executeFleetAgentMutation,
FleetAgentMutationError,
type FleetAgentMutationAgent,
type FleetAgentMutationOperation,
type FleetAgentMutationOptions,
type FleetAgentMutationRequest,
type FleetAgentMutationResult,
} from '../fleet/fleet-agent-crud.js';
import {
parseRosterV2,
ROSTER_V2_REASONING_LEVELS,
ROSTER_V2_SUPPORTED_RUNTIMES,
type FleetRosterV2,
type RosterV2ReasoningLevel,
type RosterV2RuntimeName,
} from '../fleet/roster-v2.js';
export interface FleetAgentCrudCommandDeps {
readonly mosaicHome?: string;
/** Test seam for deterministic post-roster filesystem projection failures. */
readonly projectionApplier?: FleetAgentMutationOptions['projectionApplier'];
}
interface MutationOptions {
readonly expectedGeneration: string;
readonly agent?: string;
readonly dryRun?: boolean;
readonly persistedStart?: boolean;
}
const AGENT_REQUEST_KEYS = new Set([
'name',
'alias',
'className',
'runtime',
'provider',
'model',
'reasoning',
'toolPolicy',
'workingDirectory',
'persistentPersona',
'resetBetweenTasks',
'launch',
]);
const AGENT_LAUNCH_REQUEST_KEYS = new Set(['yolo']);
/** Registers only roster-v2 desired-state mutations; it never invokes runtime actions. */
export function registerFleetAgentCrudCommands(
agentCommand: Command,
deps: FleetAgentCrudCommandDeps = {},
): void {
agentCommand
.command('get <name>')
.description('Read one local roster-v2 agent as JSON')
.action(async (name: string): Promise<void> => {
await writeJsonOutcome(async (): Promise<void> => {
const roster = await loadRoster(agentCommand, deps);
const agent = roster.agents.find((candidate): boolean => candidate.name === name);
if (!agent)
throw new FleetAgentMutationError('agent-not-found', `Agent "${name}" is not in roster.`);
printJson({ generation: roster.generation, agent });
});
});
registerMutationCommand(agentCommand, deps, 'create');
registerMutationCommand(agentCommand, deps, 'update');
registerMutationCommand(agentCommand, deps, 'delete');
agentCommand
.command('plan <operation> [name]')
.description('Plan a local roster-v2 mutation without writing roster or projections')
.requiredOption('--expected-generation <number>', 'Authoritative roster generation')
.option('--agent <json>', 'JSON agent payload for create or update')
.option(
'--persisted-start',
'Plan create with desired_state: running without starting a runtime',
)
.action(
async (operation: string, name: string | undefined, opts: MutationOptions): Promise<void> => {
await writeJsonOutcome(async (): Promise<void> => {
const parsedOperation = parseOperation(operation);
await executeCommand(
agentCommand,
deps,
parsedOperation,
opts,
true,
parsePlanTargetName(parsedOperation, name),
);
});
},
);
}
function registerMutationCommand(
agentCommand: Command,
deps: FleetAgentCrudCommandDeps,
operation: FleetAgentMutationOperation,
): void {
const command = agentCommand
.command(
operation === 'update'
? 'update <name>'
: `${operation}${operation === 'delete' ? ' <name>' : ''}`,
)
.description(`${operation} a local roster-v2 agent without runtime actions`)
.requiredOption('--expected-generation <number>', 'Authoritative roster generation')
.option('--agent <json>', 'JSON agent payload for create or update')
.option('--dry-run', 'Validate and plan without writing roster or projections');
if (operation === 'create') {
command.option(
'--persisted-start',
'Persist desired_state: running without starting a runtime',
);
command.action(async (opts: MutationOptions): Promise<void> => {
await writeJsonOutcome(async (): Promise<void> => {
await executeCommand(agentCommand, deps, operation, opts, false);
});
});
return;
}
command.action(async (name: string, opts: MutationOptions): Promise<void> => {
await writeJsonOutcome(async (): Promise<void> => {
await executeCommand(agentCommand, deps, operation, opts, false, name);
});
});
}
async function executeCommand(
agentCommand: Command,
deps: FleetAgentCrudCommandDeps,
operation: FleetAgentMutationOperation,
opts: MutationOptions,
forceDryRun: boolean,
name?: string,
): Promise<void> {
const mosaicHome = resolveMosaicHome(agentCommand, deps);
const rosterPath = resolveRosterPath(agentCommand, mosaicHome);
const roster = parseRosterV2(await readFile(rosterPath, 'utf8'), 'yaml');
const request = buildRequest(operation, opts, name);
const result = await executeFleetAgentMutation({
roster,
request,
mosaicHome,
rosterPath,
agentEnvDir: join(mosaicHome, 'fleet', 'agents'),
rolesDir: join(mosaicHome, 'fleet', 'roles'),
overrideDir: join(mosaicHome, 'fleet', 'roles.local'),
dryRun: forceDryRun || opts.dryRun === true,
...(deps.projectionApplier === undefined ? {} : { projectionApplier: deps.projectionApplier }),
});
printJson(result);
process.exitCode = fleetAgentMutationExitCode(result);
}
/** A persisted roster with failed derived projections is a non-zero CLI outcome. */
export function fleetAgentMutationExitCode(
result: Pick<FleetAgentMutationResult, 'recovery'>,
): 0 | 1 {
return result.recovery === undefined ? 0 : 1;
}
function parsePlanTargetName(
operation: FleetAgentMutationOperation,
name: string | undefined,
): string | undefined {
if (operation === 'create') {
if (name !== undefined) {
throw new FleetAgentMutationError(
'invalid-request',
'Create plan does not accept a target name.',
);
}
return undefined;
}
if (name === undefined) {
throw new FleetAgentMutationError(
'invalid-request',
`${operation} plan requires a target agent name.`,
);
}
return name;
}
function buildRequest(
operation: FleetAgentMutationOperation,
opts: MutationOptions,
name?: string,
): FleetAgentMutationRequest {
const expectedGeneration = parseExpectedGeneration(opts.expectedGeneration);
const agent = opts.agent === undefined ? undefined : parseAgent(opts.agent);
if ((operation === 'create' || operation === 'update') && !agent) {
throw new FleetAgentMutationError('invalid-request', `${operation} requires --agent JSON.`);
}
return {
operation,
expectedGeneration,
...(name === undefined ? {} : { name }),
...(agent === undefined ? {} : { agent }),
...(operation === 'create' && opts.persistedStart === true ? { persistedStart: true } : {}),
};
}
function parseExpectedGeneration(value: string): number {
const generation = Number(value);
if (!Number.isSafeInteger(generation) || generation < 1) {
throw new FleetAgentMutationError(
'invalid-request',
'--expected-generation must be a positive safe integer.',
);
}
return generation;
}
function parseOperation(value: string): FleetAgentMutationOperation {
if (value === 'create' || value === 'update' || value === 'delete') return value;
throw new FleetAgentMutationError(
'invalid-request',
'plan operation must be create, update, or delete.',
);
}
function parseAgent(source: string): FleetAgentMutationAgent {
let value: unknown;
try {
value = JSON.parse(source);
} catch {
throw new FleetAgentMutationError('invalid-request', '--agent must be valid JSON.');
}
if (!isRecord(value)) {
throw new FleetAgentMutationError('invalid-request', '--agent must be a JSON object.');
}
rejectUnknownKeys(value, AGENT_REQUEST_KEYS, '--agent');
const runtime = requiredRuntime(value, 'runtime');
const reasoning = requiredReasoning(value, 'reasoning');
const launch = requiredRecord(value, 'launch');
rejectUnknownKeys(launch, AGENT_LAUNCH_REQUEST_KEYS, '--agent.launch');
return {
name: requiredString(value, 'name'),
alias: requiredString(value, 'alias'),
className: requiredString(value, 'className'),
runtime,
provider: requiredString(value, 'provider'),
model: requiredString(value, 'model'),
reasoning,
toolPolicy: requiredString(value, 'toolPolicy'),
workingDirectory: requiredString(value, 'workingDirectory'),
persistentPersona: requiredBoolean(value, 'persistentPersona'),
resetBetweenTasks: requiredBoolean(value, 'resetBetweenTasks'),
launch: { yolo: requiredBoolean(launch, 'yolo') },
};
}
async function loadRoster(
agentCommand: Command,
deps: FleetAgentCrudCommandDeps,
): Promise<FleetRosterV2> {
const mosaicHome = resolveMosaicHome(agentCommand, deps);
const rosterPath = resolveRosterPath(agentCommand, mosaicHome);
return parseRosterV2(await readFile(rosterPath, 'utf8'), 'yaml');
}
function resolveMosaicHome(agentCommand: Command, deps: FleetAgentCrudCommandDeps): string {
const opts = agentCommand.optsWithGlobals<{ mosaicHome?: string }>();
return opts.mosaicHome ?? deps.mosaicHome ?? join(process.env['HOME'] ?? '', '.config', 'mosaic');
}
function resolveRosterPath(agentCommand: Command, mosaicHome: string): string {
const opts = agentCommand.optsWithGlobals<{ roster?: string }>();
const canonical = join(mosaicHome, 'fleet', 'roster.yaml');
if (opts.roster !== undefined && resolve(opts.roster) !== resolve(canonical)) {
throw new FleetAgentMutationError(
'invalid-request',
'Roster-v2 mutations require the canonical <mosaic-home>/fleet/roster.yaml path.',
);
}
return canonical;
}
function rejectUnknownKeys(
value: Record<string, unknown>,
allowedKeys: ReadonlySet<string>,
field: string,
): void {
const hasUnsupportedOwnProperty = Object.getOwnPropertyNames(value).some(
(key: string): boolean => !allowedKeys.has(key),
);
if (hasUnsupportedOwnProperty || Object.getOwnPropertySymbols(value).length > 0) {
throw new FleetAgentMutationError('invalid-request', `${field} contains unsupported keys.`);
}
}
function requiredString(value: Record<string, unknown>, key: string): string {
const candidate = requiredOwnValue(value, key);
if (typeof candidate !== 'string' || candidate.length === 0) {
throw new FleetAgentMutationError(
'invalid-request',
`--agent.${key} must be a non-empty string.`,
);
}
return candidate;
}
function requiredBoolean(value: Record<string, unknown>, key: string): boolean {
const candidate = requiredOwnValue(value, key);
if (typeof candidate !== 'boolean') {
throw new FleetAgentMutationError('invalid-request', `--agent.${key} must be a boolean.`);
}
return candidate;
}
function requiredRecord(value: Record<string, unknown>, key: string): Record<string, unknown> {
const candidate = requiredOwnValue(value, key);
if (!isRecord(candidate)) {
throw new FleetAgentMutationError('invalid-request', `--agent.${key} must be an object.`);
}
return candidate;
}
function requiredRuntime(value: Record<string, unknown>, key: string): RosterV2RuntimeName {
const candidate = requiredString(value, key);
if (!isRuntime(candidate)) {
throw new FleetAgentMutationError(
'invalid-request',
`--agent.${key} is not a supported runtime.`,
);
}
return candidate;
}
function requiredReasoning(value: Record<string, unknown>, key: string): RosterV2ReasoningLevel {
const candidate = requiredString(value, key);
if (!isReasoning(candidate)) {
throw new FleetAgentMutationError(
'invalid-request',
`--agent.${key} is not a supported reasoning level.`,
);
}
return candidate;
}
function isRuntime(value: string): value is RosterV2RuntimeName {
return ROSTER_V2_SUPPORTED_RUNTIMES.some(
(runtime: RosterV2RuntimeName): boolean => runtime === value,
);
}
function isReasoning(value: string): value is RosterV2ReasoningLevel {
return ROSTER_V2_REASONING_LEVELS.some(
(reasoning: RosterV2ReasoningLevel): boolean => reasoning === value,
);
}
function requiredOwnValue(value: Record<string, unknown>, key: string): unknown {
if (!Object.hasOwn(value, key)) {
throw new FleetAgentMutationError('invalid-request', `--agent.${key} is required.`);
}
return value[key];
}
function isRecord(value: unknown): value is Record<string, unknown> {
return (
typeof value === 'object' &&
value !== null &&
!Array.isArray(value) &&
Object.getPrototypeOf(value) === Object.prototype
);
}
async function writeJsonOutcome(action: () => Promise<void>): Promise<void> {
try {
await action();
} catch (error: unknown) {
process.exitCode = 1;
printJson({
error: {
code: error instanceof FleetAgentMutationError ? error.code : 'mutation-failed',
},
});
}
}
function printJson(value: object): void {
console.log(JSON.stringify(value));
}

View File

@@ -1,13 +1,18 @@
import { cp, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { cp, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { dirname, join, relative, 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';
@@ -54,13 +59,156 @@ 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 from inline markers and degrades on missing dir', async () => {
it('records class + domain and distinguishes scanned from missing directories', 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 missing = await extractClassesFromDir(join(tmp, 'nope'));
const missingDir = join(tmp, 'nope');
const missing = await extractClassesFromDir(missingDir);
expect(missing.scanState).toBe('missing');
expect(missing.classes.size).toBe(0);
expect(extractClassesFromDirSync(missingDir).scanState).toBe('missing');
});
});
@@ -81,6 +229,287 @@ 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 { readFileSync, readdirSync } from 'node:fs';
import { readFile, readdir } from 'node:fs/promises';
import { lstatSync, readFileSync, readdirSync, statSync } from 'node:fs';
import { lstat, readFile, readdir, stat } from 'node:fs/promises';
import { homedir } from 'node:os';
import { basename, join } from 'node:path';
import { basename, isAbsolute, join, sep } from 'node:path';
import type { Command } from 'commander';
function defaultMosaicHome(): string {
@@ -53,52 +53,136 @@ 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 {
/** Every class name the dir contributes (markers + filenames + LIBRARY rows). */
/** Whether the directory was scanned, absent, or present-but-unscannable. */
scanState: DirScanState;
/** Every readable class name the directory contributes by filename or first marker. */
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 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).
* 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.
*
* 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.
* 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.
*/
export async function extractClassesFromDir(dir: string): Promise<DirClasses> {
const acc: DirClasses = { classes: new Set<string>(), byClass: new Map<string, PersonaFile>() };
const acc: DirClasses = {
scanState: 'scanned',
classes: new Set<string>(),
fileStems: new Set<string>(),
byClass: new Map<string, PersonaFile>(),
};
let entries: string[];
try {
entries = await readdir(dir);
} catch {
} catch (error) {
acc.scanState = await classifyScanFailure(dir, error);
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');
@@ -117,16 +201,25 @@ 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 = { classes: new Set<string>(), byClass: new Map<string, PersonaFile>() };
const acc: DirClasses = {
scanState: 'scanned',
classes: new Set<string>(),
fileStems: new Set<string>(),
byClass: new Map<string, PersonaFile>(),
};
let entries: string[];
try {
entries = readdirSync(dir);
} catch {
} catch (error) {
acc.scanState = classifyScanFailureSync(dir, error);
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');
@@ -138,6 +231,74 @@ 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
@@ -146,33 +307,26 @@ export function extractClassesFromDirSync(dir: string): DirClasses {
*/
function accumulateEntry(acc: DirClasses, dir: string, entry: string, text: string): void {
const { classes, byClass } = acc;
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).
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.
const stem = basename(entry, '.md');
classes.add(stem);
const domainMatch = DOMAIN_MARKER.exec(text);
const domain = domainMatch?.[1];
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) });
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) });
}
}
@@ -193,24 +347,19 @@ function resolveDirs(opts: PersonaDirs): { rolesDir: string; overrideDir: string
}
/**
* 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.
* 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.
*/
export async function listPersonaClasses(opts: PersonaDirs = {}): Promise<Set<string>> {
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;
const { personas } = await collectUsablePersonas(opts);
return new Set(personas.keys());
}
export type PersonaStatus = 'baseline' | 'overridden' | 'custom';
export interface PersonaResolution {
export interface PersonaResolution extends CanonicalRoleClass {
/** Compatibility name for the canonical class. */
klass: string;
layer: PersonaLayer;
/** The file the resolved persona was read from (override wins). */
@@ -219,6 +368,118 @@ export interface PersonaResolution {
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:`
@@ -245,42 +506,32 @@ export async function resolvePersona(
* is identical to {@link resolvePersona}: override layer wins, then baseline.
*/
export async function resolvePersonaFrom(
klass: string,
requestedClass: 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 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;
}
};
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;
return (
(await fromLayer(overrideDir, over, 'override')) ??
(await fromLayer(rolesDir, base, 'baseline'))
// 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',
);
if (currentOverride) return currentOverride;
if (overrideShadowsBaseline(currentOver, klass)) return null;
if (currentOver.scanState === 'error') return null;
return readPersonaFromLayer(requested, klass, rolesDir, base, 'baseline');
}
/**
@@ -292,40 +543,55 @@ export async function resolvePersonaFrom(
* one module so the launch-time and command-time resolutions never diverge.
*/
export function resolvePersonaSync(
klass: string,
requestedClass: 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 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;
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');
}
}
try {
const content = readFileSync(pf.file, 'utf8');
return { klass, layer, file: pf.file, content, ...(pf.domain ? { domain: pf.domain } : {}) };
} catch {
return null;
}
};
return fromLayer(overrideDir, over, 'override') ?? fromLayer(rolesDir, base, 'baseline');
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 };
}
export interface PersonaStatusEntry {
@@ -342,24 +608,20 @@ export interface PersonaStatusEntry {
* Domain is taken from the WINNING layer (override domain wins if present).
*/
export async function personaStatus(opts: PersonaDirs = {}): Promise<PersonaStatusEntry[]> {
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 } : {}) });
}
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 } : {}),
};
});
entries.sort((a, b) => a.klass.localeCompare(b.klass));
return entries;
}

View File

@@ -203,6 +203,91 @@ 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,6 +29,7 @@ import {
defaultOverrideDir,
extractClassesFromDir,
listPersonaClasses as listOverrideAwarePersonaClasses,
resolvePersonaFrom,
} from './fleet-personas.js';
function defaultMosaicHome(): string {
@@ -199,6 +200,61 @@ 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;
@@ -237,10 +293,8 @@ export async function loadProfiles(opts: LoadProfilesOptions = {}): Promise<Flee
}
files.sort();
// 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);
// Validation resolves each reference to an actually readable winning persona;
// LIBRARY membership alone is not semantic success.
const profiles: FleetProfile[] = [];
const seen = new Map<string, string>();
@@ -255,11 +309,14 @@ export async function loadProfiles(opts: LoadProfilesOptions = {}): Promise<Flee
}
seen.set(profile.id, file);
const problems = validateProfile(profile, validClasses);
if (problems.length > 0) {
throw new Error(`Profile ${file} is invalid:\n - ${problems.join('\n - ')}`);
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}`);
}
profiles.push(profile);
profiles.push(resolvedProfile);
}
return profiles;
}

View File

@@ -172,6 +172,46 @@ 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,12 +26,11 @@ import type { Command } from 'commander';
import YAML from 'yaml';
import {
loadProfile,
validateProfile,
type FleetProfile,
type ProfileRosterEntry,
defaultProfilesDir,
defaultRolesDir,
listPersonaClassesWithOverrides,
resolveProfilePersonas,
} from './fleet-profiles.js';
import {
defaultOverrideDir,
@@ -201,18 +200,35 @@ export async function generateRoster(
);
}
const runtimeChoice = resolveSeatRuntime(entry.class, isFloor, isLead);
for (const name of seatNames(entry)) {
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 seat: GeneratedSeat = {
name,
className: entry.class,
className: resolved.canonicalClass,
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 (entry.reportsTo) seat.reportsTo = entry.reportsTo;
if (canonicalEntry.reportsTo) seat.reportsTo = canonicalEntry.reportsTo;
seats.push(seat);
}
}
@@ -279,13 +295,7 @@ export async function validateProfileForProvision(
opts: ProvisionOptions,
): Promise<void> {
const { rolesDir, overrideDir } = resolveDirs(opts);
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 - ')}`,
);
}
await resolveProfilePersonas(profile, rolesDir, overrideDir);
}
// ---------------------------------------------------------------------------

View File

@@ -1,4 +1,4 @@
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { chmod, lstat, mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { Command } from 'commander';
@@ -82,10 +82,14 @@ describe('registerFleetCommand', () => {
expect(fleet!.commands.map((command) => command.name()).sort()).toEqual([
'add',
'backlog',
'create',
'delete',
'get',
'init',
'install',
'install-systemd',
'persona',
'plan',
'profile',
'provision',
'ps',
@@ -94,6 +98,7 @@ describe('registerFleetCommand', () => {
'start',
'status',
'stop',
'update',
'verify',
]);
});
@@ -284,6 +289,8 @@ 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',
'',
@@ -325,57 +332,38 @@ describe('fleet roster parsing', () => {
);
});
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');
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' },
);
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'),
expect((): string => mergeAgentEnv(generated, 'MOSAIC_AGENT_COMMAND=legacy-command\n')).toThrow(
/MOSAIC_AGENT_COMMAND/,
);
});
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');
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' },
);
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);
expect(mergeAgentEnv(generated, 'MOSAIC_RUNTIME_BIN=/opt/mosaic/bin\n')).toBe(generated);
});
it('rejects unknown roster fields instead of silently defaulting', async () => {
@@ -1129,6 +1117,92 @@ 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) => {
@@ -1687,8 +1761,10 @@ describe('fleet ps — drift detection', () => {
describe('fleet-polish bundle — boot-survival symmetry', () => {
async function rosterHome(agents: string): Promise<string> {
const home = await tempDir();
await mkdir(join(home, 'fleet'), { recursive: true });
await writeFile(join(home, 'fleet', 'roster.yaml'), agents);
const fleetDir = join(home, 'fleet');
await mkdir(fleetDir, { recursive: true, mode: 0o700 });
await chmod(fleetDir, 0o700);
await writeFile(join(fleetDir, 'roster.yaml'), agents);
return home;
}
@@ -3459,7 +3535,11 @@ describe('fleet add command', () => {
async function makeHome(agents = ['orchestrator']): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'mosaic-fleet-add-'));
await mkdir(join(dir, 'fleet', 'agents'), { recursive: true });
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);
const agentLines = agents.map((name) => {
const cls = name === 'orchestrator' ? 'orchestrator' : 'worker';
return ` - name: ${name}\n runtime: claude\n class: ${cls}`;
@@ -3493,11 +3573,110 @@ 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'), 'utf8');
const envContent = await readFile(
join(home, 'fleet', 'agents', 'coder0.env.generated'),
'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[][] = [];
@@ -3659,7 +3838,10 @@ describe('fleet remove command', () => {
].join('\n'),
);
// Create env and heartbeat files for coder0
await writeFile(join(dir, 'fleet', 'agents', 'coder0.env'), 'MOSAIC_AGENT_NAME=coder0\n');
await writeFile(
join(dir, 'fleet', 'agents', 'coder0.env.generated'),
'MOSAIC_AGENT_NAME=coder0\n',
);
await writeFile(join(dir, 'fleet', 'run', 'coder0.hb'), 'ts=2026-01-01T00:00:00.000Z\n');
return dir;
}
@@ -3738,12 +3920,15 @@ describe('fleet remove command', () => {
await program.parseAsync(['node', 'mosaic', 'fleet', 'remove', 'coder0', '--keep-files']);
// Env file should still exist
const envContent = await readFile(join(home, 'fleet', 'agents', 'coder0.env'), 'utf8');
// Generated projection should still exist.
const envContent = await readFile(
join(home, 'fleet', 'agents', 'coder0.env.generated'),
'utf8',
);
expect(envContent).toContain('MOSAIC_AGENT_NAME=coder0');
});
it('env file is removed by default (no --keep-files)', async () => {
it('generated projection is removed by default (no --keep-files)', async () => {
home = await makeHome();
const runner: CommandRunner = async () => ({ stdout: '', stderr: '', exitCode: 0 });
const program = new Command();
@@ -3752,7 +3937,9 @@ describe('fleet remove command', () => {
await program.parseAsync(['node', 'mosaic', 'fleet', 'remove', 'coder0']);
await expect(readFile(join(home, 'fleet', 'agents', 'coder0.env'), 'utf8')).rejects.toThrow();
await expect(
readFile(join(home, 'fleet', 'agents', 'coder0.env.generated'), 'utf8'),
).rejects.toThrow();
});
it('removing the sole orchestrator throws with a clear error about the guard', async () => {

View File

@@ -18,7 +18,21 @@ import { spawn } from 'node:child_process';
import * as readline from 'node:readline';
import type { Command } from 'commander';
import YAML from 'yaml';
import {
registerFleetAgentCrudCommands,
type FleetAgentCrudCommandDeps,
} from './fleet-agent-crud-command.js';
import { 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';
@@ -63,6 +77,7 @@ export interface FleetCommandDeps {
* Tests stub this to simulate interactive or non-interactive environments.
*/
isStdinTTY?: boolean;
projectionApplier?: FleetAgentCrudCommandDeps['projectionApplier'];
}
interface RawFleetRoster {
@@ -509,50 +524,35 @@ export async function generateNorthStarMarkdown(repoRoot?: string): Promise<stri
}
export function generateAgentEnv(roster: FleetRoster, agent: FleetAgent): string {
const workingDirectory = agent.workingDirectory ?? roster.defaults.workingDirectory;
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');
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 {
if (!existingEnv?.trim()) {
parseAgentEnvironment(generatedEnv, 'generated');
if (existingEnv?.trim()) parseAgentEnvironment(existingEnv, 'local');
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');
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,
};
}
export function buildFleetServiceCommand(action: FleetServiceAction, agentName?: string): string[] {
@@ -1544,8 +1544,12 @@ 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);
}
// Guarantee the two-agent floor: exactly one orchestrator AND at least
// one enhancer for every profile except the sanctioned no-orchestrator
@@ -1948,15 +1952,17 @@ 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));
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),
);
await applyPreparedAgentEnvironmentProjection(preparedProjection);
console.log(`Added ${name} (${opts.runtime}/${opts.class}) to the fleet.`);
@@ -2037,10 +2043,11 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
// Write updated roster
await writeFile(rosterPath, serializeRosterToYaml(updatedRoster));
// Delete env and heartbeat files (best-effort, non-fatal)
// Delete the non-authoritative generated projection and heartbeat files
// (best-effort, non-fatal). Operator local/quarantine data is retained.
if (!opts.keepFiles) {
try {
await unlink(join(activePaths.agentEnvDir, `${name}.env`));
await unlink(join(activePaths.agentEnvDir, `${name}.env.generated`));
} catch {
// best-effort
}
@@ -2072,6 +2079,10 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
// profile. DRY-RUN by default; --write persists under the same --mosaic-home.
registerFleetProvisionCommand(cmd, () => cmd.opts<{ mosaicHome: string }>().mosaicHome);
// Roster-v2 desired-state mutations belong directly to the fleet control
// plane; they do not share the root `mosaic agent` gateway-backed surface.
registerFleetAgentCrudCommands(cmd, deps);
return cmd;
}
@@ -2328,16 +2339,17 @@ 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',
@@ -2347,6 +2359,7 @@ async function installFleet(cmd: Command, frameworkRoot: string): Promise<void>
const executableToolPaths = [
startAgentSessionPath,
startInteractionServicePath,
startTmuxHolderPath,
printInteractionPolicyPath,
sendMessagePath,
agentSendPath,
@@ -2359,6 +2372,10 @@ 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,
@@ -2382,9 +2399,12 @@ async function installFleet(cmd: Command, frameworkRoot: string): Promise<void>
);
for (const agent of roster.agents) {
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));
await writeAgentEnvironmentProjection({
mosaicHome: activePaths.mosaicHome,
agentEnvDir: activePaths.agentEnvDir,
agentName: agent.name,
generated: generateAgentEnvValues(roster, agent),
});
}
console.log(`Installed fleet files for ${roster.agents.length} agent(s).`);
@@ -2656,19 +2676,6 @@ 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) {
@@ -2770,13 +2777,8 @@ export function countEnhancers(roster: FleetRoster): number {
}
/** Valid runtime identifiers for fleet agents. */
export const VALID_FLEET_RUNTIMES: readonly string[] = [
'pi',
'claude',
'codex',
'opencode',
'dogfood',
];
/** Runtime launchers accepted by `fleet add`; shared with the generated projection validator. */
export const VALID_FLEET_RUNTIMES: readonly string[] = GENERATED_AGENT_ENV_SUPPORTED_RUNTIMES;
/**
* Add a new agent to a fleet roster (immutable — returns a new FleetRoster).

View File

@@ -0,0 +1,90 @@
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

@@ -0,0 +1,121 @@
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

@@ -0,0 +1,220 @@
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import {
FleetAgentMutationError,
executeFleetAgentMutation,
planFleetAgentMutation,
type FleetAgentMutationRequest,
} from './fleet-agent-crud.js';
import { parseRosterV2, renderRosterV2Yaml } from './roster-v2.js';
const roster = parseRosterV2(`
version: 2
generation: 7
transport: tmux
tmux:
socket_name: mosaic-fleet
holder_session: _holder
defaults:
working_directory: /srv/mosaic
runtime: pi
runtimes:
pi:
reset_command: /new
agents:
- name: orchestrator
alias: Orchestrator
class: orchestrator
runtime: pi
provider: openai
model: gpt-5.6-sol
reasoning: high
tool_policy: orchestrator
working_directory: /srv/mosaic
persistent_persona: true
reset_between_tasks: false
lifecycle:
enabled: true
desired_state: stopped
launch:
yolo: true
`);
function createRequest(): FleetAgentMutationRequest {
return {
operation: 'create',
expectedGeneration: 7,
agent: {
name: 'coder0',
alias: 'Coder 0',
className: 'code',
runtime: 'pi',
provider: 'openai',
model: 'gpt-5.6-sol',
reasoning: 'high',
toolPolicy: 'code',
workingDirectory: '/srv/mosaic',
persistentPersona: false,
resetBetweenTasks: true,
launch: { yolo: true },
},
};
}
let cleanup: string | undefined;
afterEach(async (): Promise<void> => {
if (cleanup) await rm(cleanup, { recursive: true, force: true });
cleanup = undefined;
});
async function semanticDirs(): Promise<{ rolesDir: string; overrideDir: string }> {
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-fleet-crud-'));
const rolesDir = join(cleanup, 'roles');
const overrideDir = join(cleanup, 'roles.local');
await mkdir(rolesDir, { recursive: true });
await mkdir(overrideDir, { recursive: true });
for (const klass of ['orchestrator', 'code']) {
await writeFile(join(rolesDir, `${klass}.md`), `# ${klass}\n\n(\`class: ${klass}\`)\n`);
}
return { rolesDir, overrideDir };
}
describe('fleet agent CRUD plan', (): void => {
it('creates stopped-by-default desired state and a deterministic generation plan', (): void => {
const plan = planFleetAgentMutation(roster, createRequest());
expect(plan).toMatchObject({
operation: 'create',
currentGeneration: 7,
nextGeneration: 8,
changed: true,
agent: { name: 'coder0', lifecycle: { enabled: true, desiredState: 'stopped' } },
});
});
it('rejects a stale generation before proposing effects', (): void => {
expect((): void => {
planFleetAgentMutation(roster, { ...createRequest(), expectedGeneration: 6 });
}).toThrow(FleetAgentMutationError);
});
it('treats an equivalent retry as an idempotent no-op', (): void => {
const created = planFleetAgentMutation(roster, createRequest()).roster;
const retried = planFleetAgentMutation(created, {
...createRequest(),
expectedGeneration: created.generation,
});
expect(retried).toMatchObject({ changed: false, currentGeneration: 8, nextGeneration: 8 });
});
});
describe('fleet agent CRUD execution', (): void => {
it('keeps roster and projections unchanged for dry-run', async (): Promise<void> => {
const dirs = await semanticDirs();
const home = join(cleanup!, 'mosaic');
const rosterPath = join(home, 'fleet', 'roster.yaml');
await mkdir(join(home, 'fleet', 'agents'), { recursive: true, mode: 0o700 });
await writeFile(rosterPath, 'before\n', { mode: 0o600 });
const result = await executeFleetAgentMutation({
roster,
request: createRequest(),
mosaicHome: home,
rosterPath,
agentEnvDir: join(home, 'fleet', 'agents'),
rolesDir: dirs.rolesDir,
overrideDir: dirs.overrideDir,
dryRun: true,
});
expect(result.applied).toBe(false);
expect(await readFile(rosterPath, 'utf8')).toBe('before\n');
await expect(
readFile(join(home, 'fleet', 'agents', 'coder0.env.generated'), 'utf8'),
).rejects.toThrow();
});
it('fails closed when another writer owns the mutation lock', async (): Promise<void> => {
const dirs = await semanticDirs();
const home = join(cleanup!, 'mosaic');
const rosterPath = join(home, 'fleet', 'roster.yaml');
await mkdir(join(home, 'fleet', 'agents'), { recursive: true, mode: 0o700 });
await writeFile(rosterPath, renderRosterV2Yaml(roster), { mode: 0o600 });
await writeFile(`${rosterPath}.mutation.lock`, 'owned\n', { mode: 0o600 });
await expect(
executeFleetAgentMutation({
roster,
request: createRequest(),
mosaicHome: home,
rosterPath,
agentEnvDir: join(home, 'fleet', 'agents'),
rolesDir: dirs.rolesDir,
overrideDir: dirs.overrideDir,
}),
).rejects.toMatchObject({ code: 'concurrent-mutation' });
expect(await readFile(rosterPath, 'utf8')).toBe(renderRosterV2Yaml(roster));
});
it('deletes only the removed agent generated projection when it is stale or absent', async (): Promise<void> => {
const dirs = await semanticDirs();
const home = join(cleanup!, 'mosaic');
const rosterPath = join(home, 'fleet', 'roster.yaml');
const agentEnvDir = join(home, 'fleet', 'agents');
const created = planFleetAgentMutation(roster, createRequest()).roster;
await mkdir(agentEnvDir, { recursive: true, mode: 0o700 });
await writeFile(rosterPath, renderRosterV2Yaml(created), { mode: 0o600 });
await writeFile(join(agentEnvDir, 'coder0.env.local'), 'MOSAIC_RUNTIME_BIN=/usr/bin/pi\n', {
mode: 0o600,
});
const result = await executeFleetAgentMutation({
roster: created,
request: { operation: 'delete', expectedGeneration: 8, name: 'coder0' },
mosaicHome: home,
rosterPath,
agentEnvDir,
rolesDir: dirs.rolesDir,
overrideDir: dirs.overrideDir,
});
expect(result).toMatchObject({ applied: true, plan: { nextGeneration: 9 } });
await expect(readFile(join(agentEnvDir, 'coder0.env.generated'), 'utf8')).rejects.toThrow();
expect(await readFile(join(agentEnvDir, 'coder0.env.local'), 'utf8')).toBe(
'MOSAIC_RUNTIME_BIN=/usr/bin/pi\n',
);
});
it('returns redacted projection recovery after the authoritative roster write', async (): Promise<void> => {
const dirs = await semanticDirs();
const home = join(cleanup!, 'mosaic');
const rosterPath = join(home, 'fleet', 'roster.yaml');
await mkdir(join(home, 'fleet', 'agents'), { recursive: true, mode: 0o700 });
await writeFile(rosterPath, renderRosterV2Yaml(roster), { mode: 0o600 });
const result = await executeFleetAgentMutation({
roster,
request: createRequest(),
mosaicHome: home,
rosterPath,
agentEnvDir: join(home, 'fleet', 'agents'),
rolesDir: dirs.rolesDir,
overrideDir: dirs.overrideDir,
projectionApplier: async (): Promise<void> => {
throw new Error('MOSAIC_AGENT_COMMAND=must-not-leak');
},
});
expect(result).toMatchObject({
applied: false,
authoritativeRoster: 'committed',
projections: 'incomplete',
recovery: { code: 'projection-apply-failed', action: 'regenerate-projections-from-roster' },
});
expect(JSON.stringify(result)).not.toContain('must-not-leak');
expect(parseRosterV2(await readFile(rosterPath, 'utf8'), 'yaml').generation).toBe(8);
});
});

View File

@@ -0,0 +1,397 @@
import { open, readFile, unlink } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import {
applyPreparedAgentEnvironmentProjection,
prepareAgentGeneratedProjectionDeletion,
prepareAgentEnvironmentProjection,
type PreparedAgentEnvironmentProjection,
writeManagedFleetRoster,
} from './generated-env-boundary.js';
import {
parseRosterV2,
renderRosterV2Yaml,
validateRosterV2Semantics,
type FleetRosterV2,
type FleetRosterV2Agent,
type FleetRosterV2Launch,
type FleetRosterV2Lifecycle,
type RosterV2ReasoningLevel,
type RosterV2RuntimeName,
} from './roster-v2.js';
export type FleetAgentMutationOperation = 'create' | 'update' | 'delete';
export interface FleetAgentMutationAgent {
readonly name: string;
readonly alias: string;
readonly className: string;
readonly runtime: RosterV2RuntimeName;
readonly provider: string;
readonly model: string;
readonly reasoning: RosterV2ReasoningLevel;
readonly toolPolicy: string;
readonly workingDirectory: string;
readonly persistentPersona: boolean;
readonly resetBetweenTasks: boolean;
readonly launch: FleetRosterV2Launch;
}
export interface FleetAgentMutationRequest {
readonly operation: FleetAgentMutationOperation;
readonly expectedGeneration: number;
readonly name?: string;
readonly agent?: FleetAgentMutationAgent;
readonly persistedStart?: boolean;
}
export interface FleetAgentMutationPlan {
readonly operation: FleetAgentMutationOperation;
readonly currentGeneration: number;
readonly nextGeneration: number;
readonly changed: boolean;
readonly roster: FleetRosterV2;
readonly agent?: FleetRosterV2Agent;
}
export type FleetAgentMutationAuthoritativeRosterState = 'unchanged' | 'committed';
export type FleetAgentMutationProjectionState = 'not-applied' | 'complete' | 'incomplete';
export interface FleetAgentMutationResult {
/** True only when every requested roster and projection write completed. */
readonly applied: boolean;
/** Whether the authoritative roster changed on disk. */
readonly authoritativeRoster: FleetAgentMutationAuthoritativeRosterState;
/** Whether derived projections were skipped, complete, or require recovery. */
readonly projections: FleetAgentMutationProjectionState;
readonly plan: FleetAgentMutationPlan;
readonly recovery?: FleetAgentMutationRecovery;
}
export interface FleetAgentMutationRecovery {
readonly code: 'projection-apply-failed';
readonly rosterPath: string;
readonly action: 'regenerate-projections-from-roster';
}
export interface FleetAgentMutationOptions {
readonly roster: FleetRosterV2;
readonly request: FleetAgentMutationRequest;
readonly mosaicHome: string;
readonly rosterPath: string;
readonly agentEnvDir: string;
readonly rolesDir: string;
readonly overrideDir: string;
readonly dryRun?: boolean;
readonly projectionApplier?: (prepared: PreparedAgentEnvironmentProjection) => Promise<unknown>;
}
export class FleetAgentMutationError extends Error {
constructor(
readonly code:
| 'stale-generation'
| 'agent-conflict'
| 'agent-not-found'
| 'invalid-request'
| 'concurrent-mutation'
| 'projection-apply-failed',
message: string,
) {
super(message);
this.name = FleetAgentMutationError.name;
}
}
/** Plans a generation-guarded desired-state mutation without writing any file. */
export function planFleetAgentMutation(
roster: FleetRosterV2,
request: FleetAgentMutationRequest,
): FleetAgentMutationPlan {
if (request.expectedGeneration !== roster.generation) {
throw new FleetAgentMutationError(
'stale-generation',
`Expected roster generation ${request.expectedGeneration}; current generation is ${roster.generation}.`,
);
}
if (request.operation === 'create') return planCreate(roster, request);
if (request.operation === 'update') return planUpdate(roster, request);
return planDelete(roster, request);
}
/**
* Validates the complete proposed roster and its deterministic projections before
* changing the roster authority. The only post-roster write is a derived projection.
*/
export async function executeFleetAgentMutation(
options: FleetAgentMutationOptions,
): Promise<FleetAgentMutationResult> {
const plan = planFleetAgentMutation(options.roster, options.request);
await validateRosterV2Semantics(plan.roster, {
rolesDir: options.rolesDir,
overrideDir: options.overrideDir,
});
const prepared = await prepareProjections(plan.roster, options.mosaicHome, options.agentEnvDir);
if (plan.operation === 'delete' && plan.agent) {
// Delete validates only the exact generated projection. Local overrides,
// legacy input, and quarantine records are retained operator state.
await prepareAgentGeneratedProjectionDeletion({
mosaicHome: options.mosaicHome,
agentEnvDir: options.agentEnvDir,
agentName: plan.agent.name,
});
}
if (options.dryRun === true || !plan.changed) {
return { applied: false, authoritativeRoster: 'unchanged', projections: 'not-applied', plan };
}
const lockPath = `${options.rosterPath}.mutation.lock`;
const release = await acquireMutationLock(lockPath);
try {
const persisted = parseRosterV2(await readFile(options.rosterPath, 'utf8'), 'yaml');
if (persisted.generation !== options.roster.generation) {
throw new FleetAgentMutationError(
'stale-generation',
`Expected roster generation ${options.roster.generation}; current generation is ${persisted.generation}.`,
);
}
await writeManagedFleetRoster(
options.mosaicHome,
options.rosterPath,
renderRosterV2Yaml(plan.roster),
);
const apply = options.projectionApplier ?? applyPreparedAgentEnvironmentProjection;
try {
for (const projection of prepared) await apply(projection);
if (plan.operation === 'delete' && plan.agent) {
await prepareAgentGeneratedProjectionDeletion({
mosaicHome: options.mosaicHome,
agentEnvDir: options.agentEnvDir,
agentName: plan.agent.name,
});
await removeGeneratedProjection(options.agentEnvDir, plan.agent.name);
}
} catch {
throw new FleetAgentMutationError(
'projection-apply-failed',
`Projection application failed after roster write; regenerate projections from ${options.rosterPath}.`,
);
}
return { applied: true, authoritativeRoster: 'committed', projections: 'complete', plan };
} catch (error: unknown) {
if (error instanceof FleetAgentMutationError && error.code === 'projection-apply-failed') {
return {
applied: false,
authoritativeRoster: 'committed',
projections: 'incomplete',
plan,
recovery: {
code: 'projection-apply-failed',
rosterPath: options.rosterPath,
action: 'regenerate-projections-from-roster',
},
};
}
throw error;
} finally {
await release();
}
}
function planCreate(
roster: FleetRosterV2,
request: FleetAgentMutationRequest,
): FleetAgentMutationPlan {
if (!request.agent)
throw new FleetAgentMutationError('invalid-request', 'Create requires an agent.');
const existing = roster.agents.find(
(agent: FleetRosterV2Agent): boolean => agent.name === request.agent?.name,
);
const created = toRosterAgent(request.agent, request.persistedStart === true);
if (existing) {
if (sameAgent(existing, created)) return unchangedPlan(roster, request.operation, existing);
throw new FleetAgentMutationError('agent-conflict', `Agent "${created.name}" already exists.`);
}
return changedPlan(roster, request.operation, [...roster.agents, created], created);
}
function planUpdate(
roster: FleetRosterV2,
request: FleetAgentMutationRequest,
): FleetAgentMutationPlan {
if (!request.name || !request.agent) {
throw new FleetAgentMutationError('invalid-request', 'Update requires name and agent.');
}
const existing = roster.agents.find(
(agent: FleetRosterV2Agent): boolean => agent.name === request.name,
);
if (!existing)
throw new FleetAgentMutationError(
'agent-not-found',
`Agent "${request.name}" is not in roster.`,
);
if (request.agent.name !== request.name) {
throw new FleetAgentMutationError(
'invalid-request',
'Agent names are immutable during update.',
);
}
const updated = {
...toRosterAgent(request.agent, existing.lifecycle.desiredState === 'running'),
lifecycle: existing.lifecycle,
};
if (sameAgent(existing, updated)) return unchangedPlan(roster, request.operation, existing);
return changedPlan(
roster,
request.operation,
roster.agents.map(
(agent: FleetRosterV2Agent): FleetRosterV2Agent =>
agent.name === request.name ? updated : agent,
),
updated,
);
}
function planDelete(
roster: FleetRosterV2,
request: FleetAgentMutationRequest,
): FleetAgentMutationPlan {
if (!request.name) throw new FleetAgentMutationError('invalid-request', 'Delete requires name.');
const existing = roster.agents.find(
(agent: FleetRosterV2Agent): boolean => agent.name === request.name,
);
if (!existing) return unchangedPlan(roster, request.operation);
return changedPlan(
roster,
request.operation,
roster.agents.filter((agent: FleetRosterV2Agent): boolean => agent.name !== request.name),
existing,
);
}
function toRosterAgent(
input: FleetAgentMutationAgent,
persistedStart: boolean,
): FleetRosterV2Agent {
const lifecycle: FleetRosterV2Lifecycle = {
enabled: true,
desiredState: persistedStart ? 'running' : 'stopped',
};
return { ...input, lifecycle };
}
function changedPlan(
roster: FleetRosterV2,
operation: FleetAgentMutationOperation,
agents: readonly FleetRosterV2Agent[],
agent?: FleetRosterV2Agent,
): FleetAgentMutationPlan {
const proposed = { ...roster, generation: roster.generation + 1, agents };
const normalized = parseRosterV2(renderRosterV2Yaml(proposed), 'yaml');
return {
operation,
currentGeneration: roster.generation,
nextGeneration: normalized.generation,
changed: true,
roster: normalized,
...(agent ? { agent } : {}),
};
}
function unchangedPlan(
roster: FleetRosterV2,
operation: FleetAgentMutationOperation,
agent?: FleetRosterV2Agent,
): FleetAgentMutationPlan {
return {
operation,
currentGeneration: roster.generation,
nextGeneration: roster.generation,
changed: false,
roster,
...(agent ? { agent } : {}),
};
}
function sameAgent(left: FleetRosterV2Agent, right: FleetRosterV2Agent): boolean {
return (
left.name === right.name &&
left.alias === right.alias &&
left.className === right.className &&
left.runtime === right.runtime &&
left.provider === right.provider &&
left.model === right.model &&
left.reasoning === right.reasoning &&
left.toolPolicy === right.toolPolicy &&
left.workingDirectory === right.workingDirectory &&
left.persistentPersona === right.persistentPersona &&
left.resetBetweenTasks === right.resetBetweenTasks &&
left.launch.yolo === right.launch.yolo &&
left.lifecycle.enabled === right.lifecycle.enabled &&
left.lifecycle.desiredState === right.lifecycle.desiredState
);
}
async function prepareProjections(
roster: FleetRosterV2,
mosaicHome: string,
agentEnvDir: string,
): Promise<readonly PreparedAgentEnvironmentProjection[]> {
const projections: PreparedAgentEnvironmentProjection[] = [];
for (const agent of roster.agents) {
projections.push(
await prepareAgentEnvironmentProjection({
mosaicHome,
agentEnvDir,
agentName: agent.name,
generated: generatedValues(roster, agent),
}),
);
}
return projections;
}
function generatedValues(
roster: FleetRosterV2,
agent: FleetRosterV2Agent,
): Readonly<Record<string, string>> {
return {
MOSAIC_AGENT_NAME: agent.name,
MOSAIC_AGENT_CLASS: agent.className,
MOSAIC_AGENT_RUNTIME: agent.runtime,
MOSAIC_AGENT_MODEL: agent.model,
MOSAIC_AGENT_REASONING: agent.reasoning,
MOSAIC_AGENT_TOOL_POLICY: agent.toolPolicy,
MOSAIC_AGENT_WORKDIR: agent.workingDirectory,
MOSAIC_TMUX_SOCKET: roster.tmux.socketName,
};
}
async function removeGeneratedProjection(agentEnvDir: string, agentName: string): Promise<void> {
try {
await unlink(join(agentEnvDir, `${agentName}.env.generated`));
} catch (error: unknown) {
if (isMissingFile(error)) return;
throw error;
}
}
function isMissingFile(error: unknown): boolean {
return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT';
}
async function acquireMutationLock(lockPath: string): Promise<() => Promise<void>> {
try {
const handle = await open(lockPath, 'wx', 0o600);
await handle.close();
} catch {
throw new FleetAgentMutationError(
'concurrent-mutation',
`Another roster mutation is in progress for ${dirname(lockPath)}.`,
);
}
return async (): Promise<void> => {
await unlink(lockPath).catch((): void => {});
};
}

View File

@@ -0,0 +1,279 @@
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

@@ -0,0 +1,532 @@
import { createHash, randomUUID } from 'node:crypto';
import { chmod, lstat, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
import { dirname, join, resolve } from 'node:path';
export type AgentEnvironmentKind = 'generated' | 'local';
export interface AgentEnvironmentDiagnostic {
readonly code: string;
readonly key: string;
readonly sha256: string;
}
export interface AgentEnvironmentProjectionOptions {
readonly mosaicHome: string;
readonly agentEnvDir: string;
readonly agentName: string;
readonly generated: Readonly<Record<string, string>>;
}
export interface AgentGeneratedProjectionDeletionOptions {
readonly mosaicHome: string;
readonly agentEnvDir: string;
readonly agentName: string;
}
export interface AgentEnvironmentProjectionResult {
readonly generatedPath: string;
readonly localPath: string;
readonly quarantinePath?: string;
readonly diagnostics: readonly AgentEnvironmentDiagnostic[];
}
/** A projection fully validated without changing managed files. */
export interface PreparedAgentEnvironmentProjection {
readonly mosaicHome: string;
readonly agentEnvDir: string;
readonly generatedPath: string;
readonly localPath: string;
readonly legacyPath: string;
readonly generated: string;
readonly local: string;
readonly legacy?: string;
readonly quarantinePath?: string;
readonly diagnostics: readonly AgentEnvironmentDiagnostic[];
}
export class AgentEnvBoundaryError extends Error {
readonly diagnostic: AgentEnvironmentDiagnostic;
constructor(code: string, key: string, value: string) {
const diagnostic: AgentEnvironmentDiagnostic = {
code,
key,
sha256: hashValue(value),
};
super(`Agent environment rejected: code=${code} key=${key} sha256=${diagnostic.sha256}`);
this.name = 'AgentEnvBoundaryError';
this.diagnostic = diagnostic;
}
}
export const GENERATED_AGENT_ENV_KEYS = [
'MOSAIC_AGENT_NAME',
'MOSAIC_AGENT_CLASS',
'MOSAIC_AGENT_RUNTIME',
'MOSAIC_AGENT_MODEL',
'MOSAIC_AGENT_REASONING',
'MOSAIC_AGENT_TOOL_POLICY',
'MOSAIC_AGENT_WORKDIR',
'MOSAIC_TMUX_SOCKET',
] as const;
export const LOCAL_AGENT_ENV_KEYS = [
'MOSAIC_RUNTIME_BIN',
'MOSAIC_HEARTBEAT_RUN_DIR',
'MOSAIC_HEARTBEAT_INTERVAL',
'MOSAIC_CLAUDE_JSON',
'CLAUDE_CONFIG_DIR',
] as const;
const GENERATED_KEY_SET = new Set<string>(GENERATED_AGENT_ENV_KEYS);
const LOCAL_KEY_SET = new Set<string>(LOCAL_AGENT_ENV_KEYS);
const SENSITIVE_KEY = /(?:API[_-]?KEY|AUTH|CREDENTIAL|PASSWORD|PRIVATE|SECRET|TOKEN)/i;
const AGENT_NAME = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
const POLICY_NAME = /^[a-z][a-z0-9-]*$/;
const TMUX_SOCKET = /^[A-Za-z0-9_.-]*$/;
const MODEL = /^[A-Za-z0-9._/:+-]*$/;
/** Runtime launchers supported by the generated environment contract. */
export const GENERATED_AGENT_ENV_SUPPORTED_RUNTIMES = [
'claude',
'codex',
'opencode',
'pi',
] as const;
const SUPPORTED_RUNTIMES = new Set<string>(GENERATED_AGENT_ENV_SUPPORTED_RUNTIMES);
const REASONING = new Set(['', 'low', 'medium', 'high']);
/** Parses a strict data-only generated or local environment file without shell evaluation. */
export function parseAgentEnvironment(
source: string,
kind: AgentEnvironmentKind,
): Readonly<Record<string, string>> {
const values: Record<string, string> = {};
const seen = new Set<string>();
for (const line of source.split('\n')) {
if (line === '') continue;
const match = /^([A-Z][A-Z0-9_]*)=(.*)$/.exec(line);
if (!match) throw new AgentEnvBoundaryError('malformed-line', '(malformed)', line);
const [, key, value] = match;
if (key === undefined || value === undefined) {
throw new AgentEnvBoundaryError('malformed-line', '(malformed)', line);
}
if (seen.has(key)) throw new AgentEnvBoundaryError('duplicate-key', key, value);
seen.add(key);
if (SENSITIVE_KEY.test(key)) throw new AgentEnvBoundaryError('sensitive-key', key, value);
if (containsShellSyntax(value)) throw new AgentEnvBoundaryError('unsafe-value', key, value);
if (kind === 'generated') {
if (!GENERATED_KEY_SET.has(key)) throw new AgentEnvBoundaryError('unknown-key', key, value);
} else {
if (GENERATED_KEY_SET.has(key))
throw new AgentEnvBoundaryError('generated-key-shadow', key, value);
if (!LOCAL_KEY_SET.has(key)) throw new AgentEnvBoundaryError('unknown-key', key, value);
}
values[key] = value;
}
if (kind === 'generated') assertGeneratedValues(values);
else assertLocalValues(values);
return Object.freeze(values);
}
/** Renders the roster-derived generated projection in a stable, complete key order. */
export function renderGeneratedAgentEnvironment(values: Readonly<Record<string, string>>): string {
const normalized = normalizeGeneratedValues(values);
return `${GENERATED_AGENT_ENV_KEYS.map((key): string => `${key}=${normalized[key]}`).join('\n')}\n`;
}
/** Validates all deterministic projection inputs and target state without mutation. */
export async function prepareAgentEnvironmentProjection(
options: AgentEnvironmentProjectionOptions,
): Promise<PreparedAgentEnvironmentProjection> {
if (!AGENT_NAME.test(options.agentName)) {
throw new AgentEnvBoundaryError('unsafe-agent-name', 'MOSAIC_AGENT_NAME', options.agentName);
}
await validatePrivateProjectionDirectory(options.mosaicHome, options.agentEnvDir);
const generatedPath = join(options.agentEnvDir, `${options.agentName}.env.generated`);
const localPath = join(options.agentEnvDir, `${options.agentName}.env.local`);
const legacyPath = join(options.agentEnvDir, `${options.agentName}.env`);
const generated = renderGeneratedAgentEnvironment(options.generated);
await assertPrivateRegularFileIfPresent(generatedPath);
const currentLocal = await readOptionalPrivateFile(localPath);
const parsedLocal =
currentLocal === undefined ? {} : parseAgentEnvironment(currentLocal, 'local');
const legacy = await readOptionalPrivateFile(legacyPath);
const legacyDisposition =
legacy === undefined ? emptyLegacyDisposition() : classifyLegacyEnvironment(legacy);
for (const [key, value] of Object.entries(legacyDisposition.localValues)) {
if (parsedLocal[key] !== undefined && parsedLocal[key] !== value) {
throw new AgentEnvBoundaryError('legacy-local-conflict', key, value);
}
}
const local = renderLocalAgentEnvironment({ ...legacyDisposition.localValues, ...parsedLocal });
const quarantinePath = legacyDisposition.diagnostics.length
? join(options.agentEnvDir, `${options.agentName}.env.quarantine`)
: undefined;
if (quarantinePath !== undefined) {
const existingQuarantine = await readOptionalPrivateFile(quarantinePath);
if (existingQuarantine !== undefined) {
throw new AgentEnvBoundaryError('quarantine-exists', '(quarantine)', existingQuarantine);
}
}
return {
mosaicHome: options.mosaicHome,
agentEnvDir: options.agentEnvDir,
generatedPath,
localPath,
legacyPath,
generated,
local,
...(legacy === undefined ? {} : { legacy }),
...(quarantinePath === undefined ? {} : { quarantinePath }),
diagnostics: legacyDisposition.diagnostics,
};
}
/**
* Validates only the exact generated projection eligible for a roster delete.
* Local overrides, legacy input, and quarantine records are operator-retained and
* intentionally excluded from delete validation and cleanup.
*/
export async function prepareAgentGeneratedProjectionDeletion(
options: AgentGeneratedProjectionDeletionOptions,
): Promise<string> {
if (!AGENT_NAME.test(options.agentName)) {
throw new AgentEnvBoundaryError('unsafe-agent-name', 'MOSAIC_AGENT_NAME', options.agentName);
}
await validatePrivateProjectionDirectory(options.mosaicHome, options.agentEnvDir);
const generatedPath = join(options.agentEnvDir, `${options.agentName}.env.generated`);
await assertPrivateRegularFileIfPresent(generatedPath);
return generatedPath;
}
/** Applies a previously prepared deterministic projection. */
export async function applyPreparedAgentEnvironmentProjection(
prepared: PreparedAgentEnvironmentProjection,
): Promise<AgentEnvironmentProjectionResult> {
await ensurePrivateProjectionDirectory(prepared.mosaicHome, prepared.agentEnvDir);
await writePrivateAtomically(prepared.generatedPath, prepared.generated);
if (prepared.local !== '') await writePrivateAtomically(prepared.localPath, prepared.local);
if (prepared.quarantinePath !== undefined && prepared.legacy !== undefined) {
await writePrivateAtomically(prepared.quarantinePath, prepared.legacy);
}
if (prepared.legacy !== undefined) await unlink(prepared.legacyPath);
return {
generatedPath: prepared.generatedPath,
localPath: prepared.localPath,
...(prepared.quarantinePath === undefined ? {} : { quarantinePath: prepared.quarantinePath }),
diagnostics: prepared.diagnostics,
};
}
/** Writes deterministic projection files and explicitly removes legacy `.env` authority. */
export async function writeAgentEnvironmentProjection(
options: AgentEnvironmentProjectionOptions,
): Promise<AgentEnvironmentProjectionResult> {
return applyPreparedAgentEnvironmentProjection(await prepareAgentEnvironmentProjection(options));
}
/** Creates the canonical managed roster path with private fresh-chain permissions. */
export async function writeManagedFleetRoster(
mosaicHome: string,
rosterPath: string,
content: string,
): Promise<void> {
const fleetDir = join(mosaicHome, 'fleet');
const expectedRosterPath = join(fleetDir, 'roster.yaml');
if (resolve(rosterPath) !== resolve(expectedRosterPath)) {
throw new AgentEnvBoundaryError('unsafe-file', '(roster)', rosterPath);
}
await ensureManagedDirectory(mosaicHome, false);
await ensureManagedDirectory(fleetDir, false);
await writePrivateAtomically(rosterPath, content);
}
/** Ensures the private install-derived identity used to own a named tmux server. */
export async function ensureFleetHolderIdentity(mosaicHome: string): Promise<string> {
const fleetDir = join(mosaicHome, 'fleet');
const runDir = join(fleetDir, 'run');
const identityPath = join(runDir, 'holder-owner');
await ensureManagedDirectory(mosaicHome, false);
await ensureManagedDirectory(fleetDir, false);
await ensureManagedDirectory(runDir, true);
const existing = await readOptionalPrivateFile(identityPath);
if (existing !== undefined) {
const identity = existing.trim();
if (!/^[a-f0-9-]{36}$/.test(identity)) {
throw new AgentEnvBoundaryError('unsafe-owner-identity', '(holder-owner)', existing);
}
return identity;
}
const identity = randomUUID();
await writePrivateAtomically(identityPath, `${identity}\n`);
return identity;
}
function normalizeGeneratedValues(
values: Readonly<Record<string, string>>,
): Readonly<Record<string, string>> {
const normalized: Record<string, string> = {};
for (const key of GENERATED_AGENT_ENV_KEYS) {
const value = values[key];
if (value === undefined) throw new AgentEnvBoundaryError('missing-key', key, '');
normalized[key] = value;
}
for (const [key, value] of Object.entries(values)) {
if (!GENERATED_KEY_SET.has(key)) throw new AgentEnvBoundaryError('unknown-key', key, value);
}
assertGeneratedValues(normalized);
return Object.freeze(normalized);
}
function renderLocalAgentEnvironment(values: Readonly<Record<string, string>>): string {
if (Object.keys(values).length === 0) return '';
const parsed = parseAgentEnvironment(
Object.entries(values)
.sort(([left], [right]): number => left.localeCompare(right))
.map(([key, value]): string => `${key}=${value}`)
.join('\n'),
'local',
);
return `${Object.entries(parsed)
.sort(([left], [right]): number => left.localeCompare(right))
.map(([key, value]): string => `${key}=${value}`)
.join('\n')}\n`;
}
function assertGeneratedValues(values: Readonly<Record<string, string>>): void {
for (const key of GENERATED_AGENT_ENV_KEYS) {
const value = values[key];
if (value === undefined) throw new AgentEnvBoundaryError('missing-key', key, '');
}
const name = requiredGeneratedValue(values, 'MOSAIC_AGENT_NAME');
const className = requiredGeneratedValue(values, 'MOSAIC_AGENT_CLASS');
const runtime = requiredGeneratedValue(values, 'MOSAIC_AGENT_RUNTIME');
const model = requiredGeneratedValue(values, 'MOSAIC_AGENT_MODEL');
const reasoning = requiredGeneratedValue(values, 'MOSAIC_AGENT_REASONING');
const toolPolicy = requiredGeneratedValue(values, 'MOSAIC_AGENT_TOOL_POLICY');
const workingDirectory = requiredGeneratedValue(values, 'MOSAIC_AGENT_WORKDIR');
const socket = requiredGeneratedValue(values, 'MOSAIC_TMUX_SOCKET');
if (!AGENT_NAME.test(name))
throw new AgentEnvBoundaryError('unsafe-agent-name', 'MOSAIC_AGENT_NAME', name);
if (!POLICY_NAME.test(className)) {
throw new AgentEnvBoundaryError('unsafe-class', 'MOSAIC_AGENT_CLASS', className);
}
if (!SUPPORTED_RUNTIMES.has(runtime)) {
throw new AgentEnvBoundaryError('unsupported-runtime', 'MOSAIC_AGENT_RUNTIME', runtime);
}
if (!MODEL.test(model))
throw new AgentEnvBoundaryError('unsafe-model', 'MOSAIC_AGENT_MODEL', model);
if (!REASONING.has(reasoning)) {
throw new AgentEnvBoundaryError('unsupported-reasoning', 'MOSAIC_AGENT_REASONING', reasoning);
}
if (toolPolicy !== '' && !POLICY_NAME.test(toolPolicy)) {
throw new AgentEnvBoundaryError('unsafe-tool-policy', 'MOSAIC_AGENT_TOOL_POLICY', toolPolicy);
}
if (!isSafeAbsolutePath(workingDirectory)) {
throw new AgentEnvBoundaryError('unsafe-path', 'MOSAIC_AGENT_WORKDIR', workingDirectory);
}
if (!TMUX_SOCKET.test(socket)) {
throw new AgentEnvBoundaryError('unsafe-socket', 'MOSAIC_TMUX_SOCKET', socket);
}
}
function requiredGeneratedValue(values: Readonly<Record<string, string>>, key: string): string {
const value = values[key];
if (value === undefined) throw new AgentEnvBoundaryError('missing-key', key, '');
return value;
}
function assertLocalValues(values: Readonly<Record<string, string>>): void {
for (const [key, value] of Object.entries(values)) {
if (key === 'MOSAIC_HEARTBEAT_INTERVAL') {
if (!/^[1-9][0-9]*$/.test(value)) {
throw new AgentEnvBoundaryError('invalid-interval', key, value);
}
continue;
}
if (!isSafeAbsolutePath(value)) throw new AgentEnvBoundaryError('unsafe-path', key, value);
}
}
function isSafeAbsolutePath(value: string): boolean {
return (
value.startsWith('/') && !value.split('/').includes('..') && !/[\s"'`$\\;&|<>(){}]/.test(value)
);
}
function containsShellSyntax(value: string): boolean {
return /["'`$\\;&|<>(){}]/.test(value);
}
interface LegacyDisposition {
readonly localValues: Readonly<Record<string, string>>;
readonly diagnostics: readonly AgentEnvironmentDiagnostic[];
}
function emptyLegacyDisposition(): LegacyDisposition {
return { localValues: {}, diagnostics: [] };
}
function classifyLegacyEnvironment(source: string): LegacyDisposition {
const localValues: Record<string, string> = {};
const diagnostics: AgentEnvironmentDiagnostic[] = [];
const seen = new Set<string>();
for (const line of source.split('\n')) {
if (line === '') continue;
const match = /^([A-Z][A-Z0-9_]*)=(.*)$/.exec(line);
if (!match || match[1] === undefined || match[2] === undefined) {
diagnostics.push(diagnostic('malformed-line', '(malformed)', line));
continue;
}
const [, key, value] = match;
if (seen.has(key)) {
diagnostics.push(diagnostic('duplicate-key', key, value));
continue;
}
seen.add(key);
if (GENERATED_KEY_SET.has(key)) continue;
try {
parseAgentEnvironment(`${key}=${value}\n`, 'local');
localValues[key] = value;
} catch (error: unknown) {
if (error instanceof AgentEnvBoundaryError) diagnostics.push(error.diagnostic);
else throw error;
}
}
return { localValues: Object.freeze(localValues), diagnostics: Object.freeze(diagnostics) };
}
function diagnostic(code: string, key: string, value: string): AgentEnvironmentDiagnostic {
return { code, key, sha256: hashValue(value) };
}
function hashValue(value: string): string {
return createHash('sha256').update(value).digest('hex');
}
async function readOptionalPrivateFile(path: string): Promise<string | undefined> {
try {
await assertPrivateRegularFile(path);
return await readFile(path, 'utf8');
} catch (error: unknown) {
if (isMissingFile(error)) return undefined;
throw error;
}
}
function isMissingFile(error: unknown): boolean {
return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT';
}
async function validatePrivateProjectionDirectory(
mosaicHome: string,
agentEnvDir: string,
): Promise<void> {
const fleetDir = join(mosaicHome, 'fleet');
const expectedAgentEnvDir = join(fleetDir, 'agents');
if (resolve(agentEnvDir) !== resolve(expectedAgentEnvDir)) {
throw new AgentEnvBoundaryError('unsafe-directory', '(directory)', agentEnvDir);
}
await assertManagedDirectoryIfPresent(mosaicHome, false);
await assertManagedDirectoryIfPresent(fleetDir, false);
await assertManagedDirectoryIfPresent(agentEnvDir, true);
}
async function ensurePrivateProjectionDirectory(
mosaicHome: string,
agentEnvDir: string,
): Promise<void> {
await validatePrivateProjectionDirectory(mosaicHome, agentEnvDir);
const fleetDir = join(mosaicHome, 'fleet');
await ensureManagedDirectory(mosaicHome, false);
await ensureManagedDirectory(fleetDir, false);
await ensureManagedDirectory(agentEnvDir, true);
}
async function ensureManagedDirectory(path: string, privateDirectory: boolean): Promise<void> {
let created = false;
try {
await lstat(path);
} catch (error: unknown) {
if (!isMissingFile(error)) throw error;
try {
await mkdir(path, { recursive: true, mode: 0o700 });
created = true;
} catch (mkdirError: unknown) {
if (!isAlreadyExists(mkdirError)) throw mkdirError;
}
}
await assertManagedDirectory(path, privateDirectory);
if (created) {
await chmod(path, 0o700);
await assertManagedDirectory(path, privateDirectory);
}
}
function isAlreadyExists(error: unknown): boolean {
return typeof error === 'object' && error !== null && 'code' in error && error.code === 'EEXIST';
}
async function assertManagedDirectoryIfPresent(
path: string,
privateDirectory: boolean,
): Promise<void> {
try {
await assertManagedDirectory(path, privateDirectory);
} catch (error: unknown) {
if (isMissingFile(error)) return;
throw error;
}
}
async function assertManagedDirectory(path: string, privateDirectory: boolean): Promise<void> {
const metadata = await lstat(path);
if (!metadata.isDirectory()) {
throw new AgentEnvBoundaryError('unsafe-directory', '(directory)', path);
}
if ((metadata.mode & 0o022) !== 0 || (privateDirectory && (metadata.mode & 0o077) !== 0)) {
throw new AgentEnvBoundaryError('unsafe-permissions', '(directory)', path);
}
}
async function assertPrivateRegularFileIfPresent(path: string): Promise<void> {
try {
await assertPrivateRegularFile(path);
} catch (error: unknown) {
if (isMissingFile(error)) return;
throw error;
}
}
async function assertPrivateRegularFile(path: string): Promise<void> {
const metadata = await lstat(path);
if (!metadata.isFile()) throw new AgentEnvBoundaryError('unsafe-file', '(file)', path);
if ((metadata.mode & 0o077) !== 0) {
throw new AgentEnvBoundaryError('unsafe-permissions', '(file)', path);
}
}
async function writePrivateAtomically(path: string, content: string): Promise<void> {
try {
await assertPrivateRegularFile(path);
} catch (error: unknown) {
if (!isMissingFile(error)) throw error;
}
const temporaryPath = join(dirname(path), `.${randomUUID()}.tmp`);
await writeFile(temporaryPath, content, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
await rename(temporaryPath, path);
}

View File

@@ -77,12 +77,25 @@ describe('readPersonaContractBlock — launch-time persona injection (A3b)', ()
});
it('injects an override-only (user-added) persona with no baseline at all', () => {
seedOverride(home, 'reviewer', '# Reviewer\n\n(`class: reviewer`)\n\nCUSTOM-ROLE.\n');
const block = readPersonaContractBlock(home, 'reviewer');
expect(block).toContain('# Persona Contract (reviewer)');
seedOverride(home, 'mascot', '# Mascot\n\n(`class: mascot`)\n\nCUSTOM-ROLE.\n');
const block = readPersonaContractBlock(home, 'mascot');
expect(block).toContain('# Persona Contract (mascot)');
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

@@ -0,0 +1,366 @@
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { afterEach, describe, expect, it } from 'vitest';
import {
ROSTER_V2_JSON_SCHEMA,
RosterV2ValidationError,
parseRosterV2,
renderRosterV2Yaml,
validateRosterV2Semantics,
} from './roster-v2.js';
const validRoster = `
version: 2
generation: 7
transport: tmux
tmux:
socket_name: mosaic-fleet
holder_session: _holder
defaults:
working_directory: ~/src
runtime: pi
runtimes:
pi:
reset_command: /new
agents:
- name: coder0
alias: Coder 0
class: code
runtime: pi
provider: openai
model: gpt-5.6-sol
reasoning: high
tool_policy: code
working_directory: ~/src
persistent_persona: false
reset_between_tasks: true
lifecycle:
enabled: true
desired_state: stopped
launch:
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');
expect(roster).toEqual({
version: 2,
generation: 7,
transport: 'tmux',
tmux: { socketName: 'mosaic-fleet', holderSession: '_holder' },
defaults: { workingDirectory: '~/src', runtime: 'pi' },
runtimes: { pi: { resetCommand: '/new' } },
agents: [
{
name: 'coder0',
alias: 'Coder 0',
className: 'code',
runtime: 'pi',
provider: 'openai',
model: 'gpt-5.6-sol',
reasoning: 'high',
toolPolicy: 'code',
workingDirectory: '~/src',
persistentPersona: false,
resetBetweenTasks: true,
lifecycle: { enabled: true, desiredState: 'stopped' },
launch: { yolo: true },
},
],
});
expect(renderRosterV2Yaml(roster)).toBe(validRoster.trimStart());
});
it('parses JSON and produces the same normalized model', (): void => {
const yaml = parseRosterV2(validRoster, 'yaml');
const json = JSON.stringify({
version: 2,
generation: 7,
transport: 'tmux',
tmux: { socket_name: 'mosaic-fleet', holder_session: '_holder' },
defaults: { working_directory: '~/src', runtime: 'pi' },
runtimes: { pi: { reset_command: '/new' } },
agents: [
{
name: 'coder0',
alias: 'Coder 0',
class: 'code',
runtime: 'pi',
provider: 'openai',
model: 'gpt-5.6-sol',
reasoning: 'high',
tool_policy: 'code',
working_directory: '~/src',
persistent_persona: false,
reset_between_tasks: true,
lifecycle: { enabled: true, desired_state: 'stopped' },
launch: { yolo: true },
},
],
});
expect(parseRosterV2(json, 'json')).toEqual(yaml);
});
it('sorts runtime and agent maps in the deterministic renderer', (): void => {
const roster = parseRosterV2(
validRoster
.replace(
'runtimes:\n pi:\n reset_command: /new',
'runtimes:\n pi:\n reset_command: /new\n codex:\n reset_command: /clear',
)
.replace(
'agents:\n - name: coder0',
'agents:\n - name: reviewer\n alias: Reviewer\n class: review\n runtime: pi\n provider: openai\n model: gpt-5.6-sol\n reasoning: medium\n tool_policy: review\n working_directory: ~/src\n persistent_persona: false\n reset_between_tasks: true\n lifecycle:\n enabled: true\n desired_state: stopped\n launch:\n yolo: true\n - name: coder0',
),
'yaml',
);
const rendered = renderRosterV2Yaml(roster);
expect(rendered.indexOf(' codex:')).toBeLessThan(rendered.indexOf(' pi:'));
expect(rendered.indexOf(' - name: coder0')).toBeLessThan(
rendered.indexOf(' - name: reviewer'),
);
});
it.each([
['v1 document', validRoster.replace('version: 2', 'version: 1'), /v1.*existing v1 path/i],
[
'unknown connector',
`${validRoster}\nconnector:\n kind: matrix\n`,
/unsupported field.*connector/i,
],
[
'remote host',
validRoster.replace(
'runtime: pi\n provider',
'runtime: pi\n host: remote\n provider',
),
/unsupported field.*host/i,
],
[
'secret reference',
validRoster.replace('model: gpt-5.6-sol', 'model: gpt-5.6-sol\n secret_ref: vault://x'),
/unsupported field.*secret_ref/i,
],
[
'channel override',
validRoster.replace('model: gpt-5.6-sol', 'model: gpt-5.6-sol\n channels: discord'),
/unsupported field.*channels/i,
],
[
'arbitrary command',
validRoster.replace('model: gpt-5.6-sol', 'model: gpt-5.6-sol\n command: whoami'),
/unsupported field.*command/i,
],
[
'gateway field',
`${validRoster}\ngateway:\n url: https://gateway.example\n`,
/unsupported field.*gateway/i,
],
[
'missing required field',
validRoster.replace(' model: gpt-5.6-sol\n', ''),
/model.*required/i,
],
[
'invalid type',
validRoster.replace('generation: 7', 'generation: seven'),
/generation.*integer/i,
],
[
'unsafe generation',
validRoster.replace('generation: 7', 'generation: 9007199254740992'),
/generation.*integer/i,
],
[
'duplicate names',
validRoster.replace(
' - name: coder0',
' - name: coder0\n alias: Duplicate\n class: code\n runtime: pi\n provider: openai\n model: gpt-5.6-sol\n reasoning: high\n tool_policy: code\n working_directory: ~/src\n persistent_persona: false\n reset_between_tasks: true\n lifecycle:\n enabled: true\n desired_state: stopped\n launch:\n yolo: true\n - name: coder0',
),
/duplicate agent name/i,
],
['invalid name', validRoster.replace('name: coder0', 'name: ../coder0'), /invalid agent name/i],
[
'invalid transport',
validRoster.replace('transport: tmux', 'transport: matrix'),
/transport.*tmux/i,
],
[
'invalid runtime',
validRoster.replace('runtime: pi', 'runtime: matrix'),
/runtime.*supported/i,
],
[
'invalid reasoning',
validRoster.replace('reasoning: high', 'reasoning: extreme'),
/reasoning.*low.*medium.*high/i,
],
[
'ambiguous socket',
validRoster.replace('socket_name: mosaic-fleet', 'socket_name: default/socket'),
/socket_name/i,
],
[
'agent socket override',
validRoster.replace(
'runtime: pi\n provider',
'runtime: pi\n socket: another\n provider',
),
/unsupported field.*socket/i,
],
])('rejects %s', (_name: string, source: string, expected: RegExp): void => {
expect((): void => {
parseRosterV2(source, 'yaml');
}).toThrow(expected);
});
it('rejects malformed JSON as a validation error', (): void => {
expect((): void => {
parseRosterV2('{', 'json');
}).toThrow(RosterV2ValidationError);
});
it('declares supported runtime map keys in the executable schema', (): void => {
expect(ROSTER_V2_JSON_SCHEMA).toMatchObject({
properties: {
runtimes: { propertyNames: { enum: ['claude', 'codex', 'opencode', 'pi'] } },
},
});
});
it('keeps the checked-in documentation schema structurally identical to the executable schema', async (): Promise<void> => {
const schemaPath = fileURLToPath(
new URL('../../../../docs/fleet/reference/roster-v2.schema.json', import.meta.url),
);
const documented = await readFile(schemaPath, 'utf8');
expect(JSON.parse(documented) as unknown).toEqual(ROSTER_V2_JSON_SCHEMA);
});
});

View File

@@ -0,0 +1,558 @@
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;
export const ROSTER_V2_DESIRED_STATES = ['running', 'stopped'] as const;
export type RosterV2RuntimeName = (typeof ROSTER_V2_SUPPORTED_RUNTIMES)[number];
export type RosterV2ReasoningLevel = (typeof ROSTER_V2_REASONING_LEVELS)[number];
export type RosterV2DesiredState = (typeof ROSTER_V2_DESIRED_STATES)[number];
export type RosterV2InputFormat = 'json' | 'yaml';
export interface FleetRosterV2Tmux {
readonly socketName: string;
readonly holderSession: string;
}
export interface FleetRosterV2Defaults {
readonly workingDirectory: string;
readonly runtime: RosterV2RuntimeName;
}
export interface FleetRosterV2Runtime {
readonly resetCommand: string;
}
export interface FleetRosterV2Lifecycle {
readonly enabled: boolean;
readonly desiredState: RosterV2DesiredState;
}
export interface FleetRosterV2Launch {
readonly yolo: boolean;
}
export interface FleetRosterV2Agent {
readonly name: string;
readonly alias: string;
readonly className: string;
readonly runtime: RosterV2RuntimeName;
readonly provider: string;
readonly model: string;
readonly reasoning: RosterV2ReasoningLevel;
readonly toolPolicy: string;
readonly workingDirectory: string;
readonly persistentPersona: boolean;
readonly resetBetweenTasks: boolean;
readonly lifecycle: FleetRosterV2Lifecycle;
readonly launch: FleetRosterV2Launch;
}
export interface FleetRosterV2 {
readonly version: 2;
readonly generation: number;
readonly transport: 'tmux';
readonly tmux: FleetRosterV2Tmux;
readonly defaults: FleetRosterV2Defaults;
readonly runtimes: Readonly<Record<string, FleetRosterV2Runtime>>;
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);
this.name = 'RosterV2ValidationError';
}
}
type JsonSchema = string | number | boolean | null | JsonSchema[] | { [key: string]: JsonSchema };
/**
* Executable v2 structural contract. The checked-in documentation schema is
* structurally compared to this value in roster-v2.spec.ts.
*/
export const ROSTER_V2_JSON_SCHEMA: JsonSchema = {
$schema: 'https://json-schema.org/draft/2020-12/schema',
$id: 'https://mosaicstack.dev/schemas/fleet/roster-v2.schema.json',
title: 'Mosaic local tmux fleet roster v2',
type: 'object',
additionalProperties: false,
required: ['version', 'generation', 'transport', 'tmux', 'defaults', 'runtimes', 'agents'],
properties: {
version: { const: 2 },
generation: { type: 'integer', minimum: 1, maximum: Number.MAX_SAFE_INTEGER },
transport: { const: 'tmux' },
tmux: {
type: 'object',
additionalProperties: false,
required: ['socket_name', 'holder_session'],
properties: {
socket_name: { type: 'string', pattern: '^[A-Za-z0-9_.-]+$' },
holder_session: { type: 'string', pattern: '^[A-Za-z0-9_.-]+$' },
},
},
defaults: {
type: 'object',
additionalProperties: false,
required: ['working_directory', 'runtime'],
properties: {
working_directory: { type: 'string', minLength: 1 },
runtime: { enum: [...ROSTER_V2_SUPPORTED_RUNTIMES] },
},
},
runtimes: {
type: 'object',
minProperties: 1,
propertyNames: { enum: [...ROSTER_V2_SUPPORTED_RUNTIMES] },
additionalProperties: {
type: 'object',
additionalProperties: false,
required: ['reset_command'],
properties: { reset_command: { type: 'string', minLength: 1 } },
},
},
agents: {
type: 'array',
minItems: 1,
items: {
type: 'object',
additionalProperties: false,
required: [
'name',
'alias',
'class',
'runtime',
'provider',
'model',
'reasoning',
'tool_policy',
'working_directory',
'persistent_persona',
'reset_between_tasks',
'lifecycle',
'launch',
],
properties: {
name: { type: 'string', pattern: '^[A-Za-z0-9][A-Za-z0-9_.-]*$' },
alias: { type: 'string', minLength: 1 },
class: { type: 'string', pattern: '^[a-z][a-z0-9-]*$' },
runtime: { enum: [...ROSTER_V2_SUPPORTED_RUNTIMES] },
provider: { type: 'string', minLength: 1 },
model: { type: 'string', minLength: 1 },
reasoning: { enum: [...ROSTER_V2_REASONING_LEVELS] },
tool_policy: { type: 'string', pattern: '^[a-z][a-z0-9-]*$' },
working_directory: { type: 'string', minLength: 1 },
persistent_persona: { type: 'boolean' },
reset_between_tasks: { type: 'boolean' },
lifecycle: {
type: 'object',
additionalProperties: false,
required: ['enabled', 'desired_state'],
properties: {
enabled: { type: 'boolean' },
desired_state: { enum: [...ROSTER_V2_DESIRED_STATES] },
},
},
launch: {
type: 'object',
additionalProperties: false,
required: ['yolo'],
properties: { yolo: { type: 'boolean' } },
},
},
},
},
},
};
const ROOT_KEYS = ['version', 'generation', 'transport', 'tmux', 'defaults', 'runtimes', 'agents'];
const TMUX_KEYS = ['socket_name', 'holder_session'];
const DEFAULT_KEYS = ['working_directory', 'runtime'];
const RUNTIME_KEYS = ['reset_command'];
const AGENT_KEYS = [
'name',
'alias',
'class',
'runtime',
'provider',
'model',
'reasoning',
'tool_policy',
'working_directory',
'persistent_persona',
'reset_between_tasks',
'lifecycle',
'launch',
];
const LIFECYCLE_KEYS = ['enabled', 'desired_state'];
const LAUNCH_KEYS = ['yolo'];
const IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
const TMUX_IDENTIFIER = /^[A-Za-z0-9_.-]+$/;
const POLICY_IDENTIFIER = /^[a-z][a-z0-9-]*$/;
/** Parses YAML or JSON and compiles only the local-tmux roster v2 contract. */
export function parseRosterV2(source: string, format?: RosterV2InputFormat): FleetRosterV2 {
const parsed = parseSource(source, format);
return normalizeRosterV2(parsed);
}
/** Renders canonical snake_case YAML with sorted runtime and agent entries. */
export function renderRosterV2Yaml(roster: FleetRosterV2): string {
const normalized = normalizeRosterV2(toSourceShape(roster));
return YAML.stringify(toSourceShape(normalized));
}
function parseSource(source: string, format?: RosterV2InputFormat): unknown {
const resolvedFormat = format ?? (source.trimStart().startsWith('{') ? 'json' : 'yaml');
try {
if (resolvedFormat === 'json') return JSON.parse(source) as unknown;
return YAML.parse(source) as unknown;
} catch (error: unknown) {
const detail = error instanceof Error ? error.message : String(error);
throw new RosterV2ValidationError(`Roster v2 ${resolvedFormat} parse failed: ${detail}`);
}
}
export function normalizeRosterV2(raw: unknown): FleetRosterV2 {
const root = requiredObject(raw, 'Roster v2');
assertKnownKeys(root, 'Roster v2', ROOT_KEYS);
if (root.version === 1) {
throw new RosterV2ValidationError(
'Roster v2 compiler rejects v1 input; use the existing v1 path until migration.',
);
}
if (root.version !== 2) throw new RosterV2ValidationError('Roster v2 version must be 2.');
const generation = requiredPositiveInteger(root.generation, 'Roster v2 generation');
const transport = requiredEnum(root.transport, 'Roster v2 transport', ['tmux'] as const);
const tmux = normalizeTmux(root.tmux);
const defaults = normalizeDefaults(root.defaults);
const runtimes = normalizeRuntimes(root.runtimes);
const agents = normalizeAgents(root.agents, runtimes);
if (!runtimes[defaults.runtime]) {
throw new RosterV2ValidationError(
`Roster v2 defaults runtime "${defaults.runtime}" must be declared in runtimes.`,
);
}
return { version: 2, generation, transport, tmux, defaults, runtimes, agents };
}
function normalizeTmux(value: unknown): FleetRosterV2Tmux {
const raw = requiredObject(value, 'Roster v2 tmux');
assertKnownKeys(raw, 'Roster v2 tmux', TMUX_KEYS);
return {
socketName: requiredTmuxIdentifier(raw.socket_name, 'Roster v2 tmux socket_name'),
holderSession: requiredTmuxIdentifier(raw.holder_session, 'Roster v2 tmux holder_session'),
};
}
function normalizeDefaults(value: unknown): FleetRosterV2Defaults {
const raw = requiredObject(value, 'Roster v2 defaults');
assertKnownKeys(raw, 'Roster v2 defaults', DEFAULT_KEYS);
return {
workingDirectory: requiredString(raw.working_directory, 'Roster v2 defaults working_directory'),
runtime: requiredRuntime(raw.runtime, 'Roster v2 defaults runtime'),
};
}
function normalizeRuntimes(value: unknown): Readonly<Record<string, FleetRosterV2Runtime>> {
const raw = requiredObject(value, 'Roster v2 runtimes');
const names = Object.keys(raw);
if (names.length === 0)
throw new RosterV2ValidationError('Roster v2 runtimes must not be empty.');
const result: Record<string, FleetRosterV2Runtime> = {};
for (const name of names.sort()) {
const runtime = requiredRuntime(name, 'Roster v2 runtime name');
const config = requiredObject(raw[name], `Roster v2 runtime "${runtime}"`);
assertKnownKeys(config, `Roster v2 runtime "${runtime}"`, RUNTIME_KEYS);
result[runtime] = {
resetCommand: requiredString(
config.reset_command,
`Roster v2 runtime "${runtime}" reset_command`,
),
};
}
return result;
}
function normalizeAgents(
value: unknown,
runtimes: Readonly<Record<string, FleetRosterV2Runtime>>,
): readonly FleetRosterV2Agent[] {
if (!Array.isArray(value) || value.length === 0) {
throw new RosterV2ValidationError('Roster v2 agents must be a non-empty array.');
}
const seen = new Set<string>();
const agents = value.map((candidate: unknown, index: number): FleetRosterV2Agent => {
const raw = requiredObject(candidate, `Roster v2 agents[${index}]`);
assertKnownKeys(raw, `Roster v2 agents[${index}]`, AGENT_KEYS);
const name = requiredIdentifier(raw.name, `Roster v2 agents[${index}] name`);
if (seen.has(name))
throw new RosterV2ValidationError(`Roster v2 has duplicate agent name: ${name}.`);
seen.add(name);
const runtime = requiredRuntime(raw.runtime, `Roster v2 agent "${name}" runtime`);
if (!runtimes[runtime]) {
throw new RosterV2ValidationError(
`Roster v2 agent "${name}" runtime "${runtime}" must be declared in runtimes.`,
);
}
return {
name,
alias: requiredString(raw.alias, `Roster v2 agent "${name}" alias`),
className: requiredPolicyIdentifier(raw.class, `Roster v2 agent "${name}" class`),
runtime,
provider: requiredString(raw.provider, `Roster v2 agent "${name}" provider`),
model: requiredString(raw.model, `Roster v2 agent "${name}" model`),
reasoning: requiredEnum(
raw.reasoning,
`Roster v2 agent "${name}" reasoning`,
ROSTER_V2_REASONING_LEVELS,
),
toolPolicy: requiredPolicyIdentifier(
raw.tool_policy,
`Roster v2 agent "${name}" tool_policy`,
),
workingDirectory: requiredString(
raw.working_directory,
`Roster v2 agent "${name}" working_directory`,
),
persistentPersona: requiredBoolean(
raw.persistent_persona,
`Roster v2 agent "${name}" persistent_persona`,
),
resetBetweenTasks: requiredBoolean(
raw.reset_between_tasks,
`Roster v2 agent "${name}" reset_between_tasks`,
),
lifecycle: normalizeLifecycle(raw.lifecycle, name),
launch: normalizeLaunch(raw.launch, name),
};
});
return agents.sort((left: FleetRosterV2Agent, right: FleetRosterV2Agent): number =>
left.name.localeCompare(right.name),
);
}
function normalizeLifecycle(value: unknown, agentName: string): FleetRosterV2Lifecycle {
const raw = requiredObject(value, `Roster v2 agent "${agentName}" lifecycle`);
assertKnownKeys(raw, `Roster v2 agent "${agentName}" lifecycle`, LIFECYCLE_KEYS);
return {
enabled: requiredBoolean(raw.enabled, `Roster v2 agent "${agentName}" lifecycle enabled`),
desiredState: requiredEnum(
raw.desired_state,
`Roster v2 agent "${agentName}" lifecycle desired_state`,
ROSTER_V2_DESIRED_STATES,
),
};
}
function normalizeLaunch(value: unknown, agentName: string): FleetRosterV2Launch {
const raw = requiredObject(value, `Roster v2 agent "${agentName}" launch`);
assertKnownKeys(raw, `Roster v2 agent "${agentName}" launch`, LAUNCH_KEYS);
return { yolo: requiredBoolean(raw.yolo, `Roster v2 agent "${agentName}" launch yolo`) };
}
function requiredObject(value: unknown, label: string): Record<string, unknown> {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
throw new RosterV2ValidationError(`${label} must be an object.`);
}
return value as Record<string, unknown>;
}
function assertKnownKeys(
value: Record<string, unknown>,
label: string,
allowedKeys: readonly string[],
): void {
const allowed = new Set(allowedKeys);
const unknown = Object.keys(value).filter((key: string): boolean => !allowed.has(key));
if (unknown.length > 0) {
throw new RosterV2ValidationError(`${label} has unsupported field(s): ${unknown.join(', ')}.`);
}
}
function requiredString(value: unknown, label: string): string {
if (typeof value !== 'string' || value.trim() === '') {
throw new RosterV2ValidationError(`${label} is required and must be a non-empty string.`);
}
return value.trim();
}
function requiredIdentifier(value: unknown, label: string): string {
const result = requiredString(value, label);
if (!IDENTIFIER.test(result)) {
throw new RosterV2ValidationError(`Invalid agent name (${label}): ${result}.`);
}
return result;
}
function requiredTmuxIdentifier(value: unknown, label: string): string {
const result = requiredString(value, label);
if (!TMUX_IDENTIFIER.test(result))
throw new RosterV2ValidationError(`Invalid ${label}: ${result}.`);
return result;
}
function requiredPolicyIdentifier(value: unknown, label: string): string {
const result = requiredString(value, label);
if (!POLICY_IDENTIFIER.test(result))
throw new RosterV2ValidationError(`Invalid ${label}: ${result}.`);
return result;
}
function requiredPositiveInteger(value: unknown, label: string): number {
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 1) {
throw new RosterV2ValidationError(`${label} must be a positive integer.`);
}
return value;
}
function requiredBoolean(value: unknown, label: string): boolean {
if (typeof value !== 'boolean') throw new RosterV2ValidationError(`${label} must be a boolean.`);
return value;
}
function requiredRuntime(value: unknown, label: string): RosterV2RuntimeName {
return requiredEnum(value, label, ROSTER_V2_SUPPORTED_RUNTIMES);
}
function requiredEnum<T extends string>(value: unknown, label: string, allowed: readonly T[]): T {
if (typeof value !== 'string' || !allowed.includes(value as T)) {
throw new RosterV2ValidationError(
`${label} must be one of the supported values: ${allowed.join(', ')}.`,
);
}
return value as T;
}
function toSourceShape(roster: FleetRosterV2): Record<string, unknown> {
return {
version: roster.version,
generation: roster.generation,
transport: roster.transport,
tmux: { socket_name: roster.tmux.socketName, holder_session: roster.tmux.holderSession },
defaults: {
working_directory: roster.defaults.workingDirectory,
runtime: roster.defaults.runtime,
},
runtimes: Object.fromEntries(
Object.entries(roster.runtimes)
.sort(([left], [right]): number => left.localeCompare(right))
.map(([name, runtime]): [string, unknown] => [
name,
{ reset_command: runtime.resetCommand },
]),
),
agents: [...roster.agents]
.sort((left: FleetRosterV2Agent, right: FleetRosterV2Agent): number =>
left.name.localeCompare(right.name),
)
.map(
(agent: FleetRosterV2Agent): Record<string, unknown> => ({
name: agent.name,
alias: agent.alias,
class: agent.className,
runtime: agent.runtime,
provider: agent.provider,
model: agent.model,
reasoning: agent.reasoning,
tool_policy: agent.toolPolicy,
working_directory: agent.workingDirectory,
persistent_persona: agent.persistentPersona,
reset_between_tasks: agent.resetBetweenTasks,
lifecycle: {
enabled: agent.lifecycle.enabled,
desired_state: agent.lifecycle.desiredState,
},
launch: { yolo: agent.launch.yolo },
}),
),
};
}

View File

@@ -0,0 +1,59 @@
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

@@ -0,0 +1,199 @@
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,3 +4,4 @@ export interface AgentSessionHandle {
}
export * from './agent-runtime-provider.js';
export * from './connector-lease.dto.js';

View File

@@ -0,0 +1,43 @@
import type {
ChannelAdapterHealthDto,
ChannelEgressDto,
ChannelIngressDto,
} from './channel.dto.js';
export type ChannelDeliveryErrorCode =
| 'invalid_route'
| 'destination_unavailable'
| 'delivery_failed';
/** Terminal adapter delivery failure surfaced to the gateway/caller. */
export class ChannelDeliveryError extends Error {
readonly name = 'ChannelDeliveryError';
constructor(
readonly code: ChannelDeliveryErrorCode,
message: string,
readonly retryable = false,
options?: ErrorOptions,
) {
super(message, options);
}
}
/** Gateway policy boundary consumed by official channel adapters. */
export interface ChannelIngressPort {
receive(ingress: ChannelIngressDto): Promise<void>;
}
/** Adapter egress boundary used by the gateway after agent output is ready. */
export interface ChannelEgressPort {
send(egress: ChannelEgressDto): Promise<void>;
}
/** Shared lifecycle seam implemented by every official channel adapter. */
export interface OfficialChannelAdapter {
readonly name: string;
start(): Promise<void>;
stop(): Promise<void>;
/** Health is best-effort and never throws for ordinary disconnected state. */
health(): Promise<ChannelAdapterHealthDto>;
}

View File

@@ -0,0 +1,97 @@
/** JSON-safe metadata carried across channel adapter boundaries. */
export type ChannelMetadataValue =
| string
| number
| boolean
| null
| readonly ChannelMetadataValue[]
| { readonly [key: string]: ChannelMetadataValue };
export type ChannelSenderKind = 'user' | 'agent' | 'system';
export type ChannelContentKind = 'text' | 'markdown' | 'code' | 'image' | 'file';
export type ChannelAdapterStatus = 'connected' | 'degraded' | 'disconnected';
export type ChannelAuthorizationRole = 'viewer' | 'operator' | 'admin';
export type ChannelOperation = 'message.send' | 'approval.create' | 'session.stop';
export interface ChannelAttachmentDto {
id: string;
name: string;
mimeType: string | null;
url: string;
sizeBytes?: number;
}
/** Canonical transport-neutral message shape for official channel adapters. */
export interface ChannelMessageDto {
id: string;
channelName: string;
channelId: string;
senderId: string;
senderKind: ChannelSenderKind;
content: string;
contentKind: ChannelContentKind;
timestamp: string;
threadId?: string;
replyToId?: string;
attachments?: readonly ChannelAttachmentDto[];
metadata: Readonly<Record<string, ChannelMetadataValue>>;
}
/** Where an adapter must deliver a response for one normalized conversation turn. */
/** Provisioned external identity after adapter allowlist/pairing checks pass. */
export interface ChannelAuthorizedPrincipalDto {
channelUserId: string;
role: ChannelAuthorizationRole;
/** Needed when gateway policy must authorize a privileged Mosaic operation. */
mosaicUserId?: string;
}
/** Configuration-owned binding. Credentials are intentionally absent. */
export interface ChannelBindingDto {
bindingId: string;
channelName: string;
workspaceId: string;
channelId: string;
logicalAgentId: string;
principals: Readonly<Record<string, ChannelAuthorizedPrincipalDto>>;
}
export interface ChannelResponseTargetDto {
channelId: string;
threadId?: string;
}
/**
* Stable channel-to-session route. Runtime provider, harness, model, process,
* and native runtime session identifiers are intentionally absent.
*/
export interface ChannelConversationRouteDto {
bindingId: string;
logicalAgentId: string;
conversationId: string;
channelName: string;
authorizationChannelId: string;
responseTarget: ChannelResponseTargetDto;
}
/** Authorized adapter-to-gateway ingress after native translation. */
export interface ChannelIngressDto {
correlationId: string;
nativeMessageId: string;
operation: ChannelOperation;
principal: ChannelAuthorizedPrincipalDto;
message: ChannelMessageDto;
route: ChannelConversationRouteDto;
}
/** Gateway-to-adapter egress; runtime/provider identity remains gateway-internal. */
export interface ChannelEgressDto {
correlationId: string;
message: ChannelMessageDto;
route: ChannelConversationRouteDto;
}
export interface ChannelAdapterHealthDto {
status: ChannelAdapterStatus;
detail?: string;
}

View File

@@ -0,0 +1,2 @@
export * from './channel-adapter.js';
export * from './channel.dto.js';

View File

@@ -1,3 +1,4 @@
import type { ChannelAttachmentDto } from '../channel/index.js';
import type {
CommandManifestPayload,
SlashCommandApprovalResultPayload,
@@ -73,6 +74,7 @@ export interface ChatMessagePayload {
provider?: string;
modelId?: string;
agentId?: string;
attachments?: readonly ChannelAttachmentDto[];
}
/** Routing decision summary included in session:info for transparency */

View File

@@ -1,5 +1,6 @@
export const VERSION = '0.0.0';
export * from './channel/index.js';
export * from './chat/index.js';
export * from './agent/index.js';
export * from './provider/index.js';

67
plugins/discord/README.md Normal file
View File

@@ -0,0 +1,67 @@
# @mosaicstack/discord-plugin
Official Discord channel adapter for Mosaic Stack.
## Behavior
- Runs independently of the bound agent's Claude, Codex, Pi, OpenCode, or future harness.
- Routes authorized untagged messages in configured agent channels and replies in-channel.
- Creates a public Discord thread when the bot is mentioned in a parent channel, or reuses the thread already attached to that same message.
- Keeps follow-ups in existing threads without requiring repeated mentions.
- Keeps `/approve` and `/stop <approval>` on the current durable session.
- Applies guild, parent-channel, user, pairing, role, and per-minute abuse limits before thread creation or gateway dispatch.
- Authenticates to the gateway and signs ingress envelopes with the injected service token.
## Required configuration
| Variable | Purpose |
| --------------------------------------- | -------------------------------------------------------------------- |
| `DISCORD_BOT_TOKEN` | Discord bot credential |
| `DISCORD_SERVICE_TOKEN` | High-entropy plugin-to-gateway credential |
| `DISCORD_SERVICE_USER_ID` | Provisioned Mosaic service principal |
| `DISCORD_ALLOWED_GUILD_IDS` | Comma-separated guild allowlist |
| `DISCORD_ALLOWED_CHANNEL_IDS` | Comma-separated parent-channel allowlist |
| `DISCORD_ALLOWED_USER_IDS` | Comma-separated Discord user allowlist |
| `DISCORD_INTERACTION_BINDINGS` | JSON channel→logical-agent bindings and paired-user roles |
| `DISCORD_GATEWAY_URL` | Gateway base URL; defaults to the gateway's local development URL |
| `DISCORD_MESSAGE_RATE_LIMIT_PER_MINUTE` | Authorized turns per guild/channel/user per minute; default `30` |
| `DISCORD_THREAD_RATE_LIMIT_PER_MINUTE` | Mention-thread routes per guild/channel/user per minute; default `5` |
Supply credentials through the approved runtime secret mechanism. Never commit tokens or binding data containing secrets.
Example binding shape (identifiers are placeholders):
```json
[
{
"instanceId": "interaction-agent",
"agentConfigId": "agent-config-id",
"guildId": "guild-id",
"channelId": "channel-id",
"pairedUsers": {
"discord-user-id": {
"role": "operator",
"mosaicUserId": "mosaic-user-id"
}
}
}
]
```
Each binding's trusted `agentConfigId` must identify a provisioned database agent configuration whose name exactly matches its `instanceId`. Roles are `viewer`, `operator`, and `admin`. `viewer` cannot send agent turns. Runtime approval and stop operations require an `admin` pairing with a provisioned `mosaicUserId`.
The bot needs Discord permissions to view/send messages in configured channels and create/send in public threads. A channel category is not an authorization boundary; threads inherit authorization only from their configured parent text channel.
## Shared contract
The adapter implements `OfficialChannelAdapter` from `@mosaicstack/types`. `ChannelConversationRouteDto` carries only stable logical-agent/channel identity and a response target. Gateway durable-session and provider layers own runtime selection and handoff; Discord code must not import a harness SDK.
## Development
```bash
pnpm --filter @mosaicstack/types build
pnpm --filter @mosaicstack/discord-plugin typecheck
pnpm --filter @mosaicstack/discord-plugin lint
pnpm --filter @mosaicstack/discord-plugin test
pnpm --filter @mosaicstack/discord-plugin build
```

View File

@@ -19,13 +19,16 @@
"dev": "tsx watch src/index.ts",
"lint": "eslint src",
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests"
"test": "vitest run --coverage",
"test:coverage": "vitest run --coverage"
},
"dependencies": {
"@mosaicstack/types": "workspace:^",
"discord.js": "^14.16.0",
"socket.io-client": "^4.8.0"
},
"devDependencies": {
"@vitest/coverage-v8": "^2.0.0",
"tsx": "^4.0.0",
"typescript": "^5.8.0",
"vitest": "^2.0.0"

View File

@@ -0,0 +1,950 @@
import { EventEmitter } from 'node:events';
import type {
ChannelConversationRouteDto,
ChannelEgressDto,
ChannelIngressDto,
ChannelIngressPort,
} from '@mosaicstack/types';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
createDiscordIngressEnvelope,
DiscordPlugin,
parseDiscordInteractionBindings,
resolveDiscordInteractionActorId,
resolveDiscordInteractionBinding,
verifyDiscordIngressEnvelope,
type DiscordIngressEnvelope,
type DiscordInteractionRole,
type DiscordPluginConfig,
} from './index.js';
const SERVICE_TOKEN = 'test-service-token';
interface FakeDiscordMessageOptions {
id?: string;
guildId?: string;
content: string;
mentioned?: boolean;
userId?: string;
channelId?: string;
parentChannelId?: string | null;
isThread?: boolean;
hasThread?: boolean;
existingThreadId?: string;
fetchedThreadId?: string;
createdThreadId?: string;
attachments?: Map<string, FakeDiscordAttachment>;
}
interface FakeDiscordAttachment {
id: string;
name: string;
url: string;
contentType: string | null;
size?: number;
}
interface FakeDiscordMessage {
id: string;
guildId: string;
channelId: string;
author: { id: string; bot: boolean };
mentions: { has(user: { id: string }): boolean };
content: string;
createdAt: Date;
channel: {
parentId: string | null;
isThread(): boolean;
threads?: { fetch(id: string): Promise<{ id: string }> };
};
attachments: Map<string, FakeDiscordAttachment>;
hasThread: boolean;
thread: { id: string } | null;
startThread: ReturnType<typeof vi.fn>;
}
interface FakeGatewaySocket extends EventEmitter {
connected: boolean;
disconnect?: ReturnType<typeof vi.fn>;
}
interface FakeLifecycleClient extends EventEmitter {
user: { id: string; tag: string };
login: ReturnType<typeof vi.fn>;
destroy: ReturnType<typeof vi.fn>;
isReady(): boolean;
guilds: { cache: Map<string, object> };
channels: { cache: Map<string, object> };
}
interface DiscordPluginInternals {
client: {
user: { id: string };
isReady(): boolean;
channels?: {
cache: { get(id: string): { send(options: unknown): Promise<void> } | undefined };
};
};
socket: {
connected: boolean;
emit: ReturnType<typeof vi.fn>;
} | null;
conversationRoutes: Map<string, ChannelConversationRouteDto>;
handleDiscordMessage(message: FakeDiscordMessage): void | Promise<void>;
sendToDiscord(conversationId: string, text: string): Promise<void>;
}
function lifecyclePlugin(): {
plugin: DiscordPlugin;
socket: FakeGatewaySocket;
client: FakeLifecycleClient;
} {
const socket = Object.assign(new EventEmitter(), {
connected: false,
disconnect: vi.fn(),
});
const client = Object.assign(new EventEmitter(), {
user: { id: 'bot-001', tag: 'mosaic-bot' },
login: vi.fn().mockResolvedValue('token'),
destroy: vi.fn().mockResolvedValue(undefined),
isReady: (): boolean => true,
guilds: { cache: new Map<string, object>() },
channels: { cache: new Map<string, object>() },
});
const plugin = new DiscordPlugin(
{
token: 'unused',
gatewayUrl: 'http://gateway.invalid',
serviceToken: SERVICE_TOKEN,
allowedGuildIds: ['guild-001'],
allowedChannelIds: ['channel-001'],
allowedUserIds: ['user-001'],
interactionBindings: [
{
instanceId: 'Nova',
agentConfigId: 'agent-config-nova',
guildId: 'guild-001',
channelId: 'channel-001',
pairedUsers: {
'user-001': { role: 'operator', mosaicUserId: 'mosaic-user-001' },
},
},
],
},
{
client: client as never,
socketFactory: vi.fn().mockReturnValue(socket) as never,
},
);
return { plugin, socket, client };
}
function createPlugin(
role: DiscordInteractionRole = 'operator',
paired = true,
ingressPort?: ChannelIngressPort,
configOverrides: Partial<DiscordPluginConfig> = {},
): {
plugin: DiscordPlugin;
internals: DiscordPluginInternals;
emit: ReturnType<typeof vi.fn>;
} {
const plugin = new DiscordPlugin(
{
token: 'unused',
gatewayUrl: 'http://unused',
serviceToken: SERVICE_TOKEN,
allowedGuildIds: ['guild-001'],
allowedChannelIds: ['channel-001'],
allowedUserIds: ['user-001'],
interactionBindings: [
{
instanceId: 'Nova',
agentConfigId: 'agent-config-nova',
guildId: 'guild-001',
channelId: 'channel-001',
pairedUsers: paired
? {
'user-001': { role, mosaicUserId: 'mosaic-user-001' },
}
: {},
},
],
...configOverrides,
},
{ ingressPort },
);
const emit = vi.fn();
const internals = plugin as unknown as DiscordPluginInternals;
internals.client = { user: { id: 'bot-001' }, isReady: (): boolean => true };
internals.socket = { connected: true, emit };
return { plugin, internals, emit };
}
function fakeMessage(options: FakeDiscordMessageOptions): FakeDiscordMessage {
const channelId = options.channelId ?? 'channel-001';
const parentChannelId = options.parentChannelId;
const existingThreadId = options.existingThreadId;
return {
id: options.id ?? 'message-001',
guildId: options.guildId ?? 'guild-001',
channelId,
author: { id: options.userId ?? 'user-001', bot: false },
mentions: { has: (): boolean => options.mentioned ?? false },
content: options.content,
createdAt: new Date('2026-07-14T12:00:00.000Z'),
channel: {
parentId: parentChannelId ?? null,
isThread: (): boolean =>
options.isThread ?? (parentChannelId !== undefined && parentChannelId !== null),
...(options.fetchedThreadId
? {
threads: {
fetch: vi.fn().mockResolvedValue({ id: options.fetchedThreadId }),
},
}
: {}),
},
attachments: options.attachments ?? new Map<string, FakeDiscordAttachment>(),
hasThread: options.hasThread ?? existingThreadId !== undefined,
thread: existingThreadId ? { id: existingThreadId } : null,
startThread: vi.fn().mockResolvedValue({ id: options.createdThreadId ?? 'thread-created-001' }),
};
}
function emittedEnvelope(emit: ReturnType<typeof vi.fn>): DiscordIngressEnvelope {
const call = emit.mock.calls[0] as [string, DiscordIngressEnvelope] | undefined;
expect(call?.[0]).toBe('message');
expect(call?.[1]).toBeDefined();
return (
call?.[1] ??
createDiscordIngressEnvelope(
{
correlationId: 'unreachable',
messageId: 'unreachable',
guildId: 'unreachable',
channelId: 'unreachable',
userId: 'unreachable',
conversationId: 'unreachable',
content: 'unreachable',
},
SERVICE_TOKEN,
)
);
}
afterEach((): void => {
vi.useRealTimers();
vi.restoreAllMocks();
});
describe('Discord binding configuration', () => {
it('parses role-only and Mosaic-linked pairings', () => {
const bindings = parseDiscordInteractionBindings(
JSON.stringify([
{
instanceId: 'Nova',
agentConfigId: 'agent-config-nova',
guildId: 'guild-001',
channelId: 'channel-001',
pairedUsers: {
'legacy-user': 'operator',
'linked-user': { role: 'admin', mosaicUserId: 'mosaic-admin-001' },
'linked-without-id': { role: 'operator' },
},
},
]),
);
expect(bindings).toHaveLength(1);
expect(resolveDiscordInteractionActorId(bindings[0]!, 'legacy-user')).toBeNull();
expect(resolveDiscordInteractionActorId(bindings[0]!, 'linked-user')).toBe('mosaic-admin-001');
expect(resolveDiscordInteractionActorId(bindings[0]!, 'linked-without-id')).toBeNull();
expect(resolveDiscordInteractionActorId(bindings[0]!, 'missing-user')).toBeNull();
expect(
resolveDiscordInteractionBinding(bindings, 'guild-001', 'channel-001', 'linked-user', 'bind'),
).toBe(bindings[0]);
expect(
resolveDiscordInteractionBinding(
bindings,
'guild-001',
'channel-001',
'linked-without-id',
'attach',
),
).toBe(bindings[0]);
expect(
resolveDiscordInteractionBinding(
bindings,
'other-guild',
'channel-001',
'linked-user',
'send',
),
).toBeNull();
});
it.each([
['missing value', undefined],
['empty array', '[]'],
['non-object binding', '[null]'],
['missing binding fields', '[{"instanceId":"Nova"}]'],
[
'empty Discord user ID',
'[{"instanceId":"Nova","guildId":"g","channelId":"c","pairedUsers":{"":"operator"}}]',
],
[
'invalid role-only pairing',
'[{"instanceId":"Nova","guildId":"g","channelId":"c","pairedUsers":{"u":"owner"}}]',
],
[
'non-object pairing',
'[{"instanceId":"Nova","guildId":"g","channelId":"c","pairedUsers":{"u":42}}]',
],
[
'invalid linked pairing',
'[{"instanceId":"Nova","guildId":"g","channelId":"c","pairedUsers":{"u":{"role":"admin","mosaicUserId":" "}}}]',
],
])('rejects %s', (_case: string, value: string | undefined) => {
expect(() => parseDiscordInteractionBindings(value)).toThrow();
});
});
describe('Discord ingress integrity', () => {
it.each([
['guild', { guildIds: ['other'], channelIds: ['channel-001'], userIds: ['user-001'] }],
['channel', { guildIds: ['guild-001'], channelIds: ['other'], userIds: ['user-001'] }],
['user', { guildIds: ['guild-001'], channelIds: ['channel-001'], userIds: ['other'] }],
])('rejects an unallowlisted %s', (_field: string, allowlists) => {
const payload = {
correlationId: 'correlation-001',
messageId: 'message-001',
guildId: 'guild-001',
channelId: 'channel-001',
userId: 'user-001',
conversationId: 'Nova:discord:channel-001',
content: 'hello',
};
const envelope = createDiscordIngressEnvelope(payload, SERVICE_TOKEN);
expect(verifyDiscordIngressEnvelope(envelope, SERVICE_TOKEN, allowlists)).toBeNull();
});
});
describe('Discord adapter lifecycle', () => {
it('starts while the in-process gateway is still connecting, then reports connected', async () => {
const { plugin, socket, client } = lifecyclePlugin();
await plugin.start();
expect(client.login).toHaveBeenCalledWith('unused');
expect(await plugin.health()).toEqual({ status: 'degraded' });
socket.connected = true;
socket.emit('connect');
expect(await plugin.health()).toEqual({ status: 'connected' });
await plugin.stop();
expect(socket.disconnect).toHaveBeenCalledOnce();
expect(client.destroy).toHaveBeenCalledOnce();
});
it('keeps running while Socket.IO reconnects after an initial gateway error', async () => {
const { plugin, socket } = lifecyclePlugin();
const error = vi.spyOn(console, 'error').mockImplementation((): void => undefined);
await plugin.start();
socket.emit('connect_error', new Error('gateway not listening yet'));
expect(error).toHaveBeenCalledWith(
'[discord] Gateway connection error: gateway not listening yet',
);
expect(await plugin.health()).toEqual({ status: 'degraded' });
});
it('cleans up when Discord login fails', async () => {
const { plugin, socket, client } = lifecyclePlugin();
client.login.mockRejectedValueOnce(new Error('Discord authentication rejected'));
await expect(plugin.start()).rejects.toThrow('Discord authentication rejected');
expect(socket.disconnect).toHaveBeenCalledOnce();
expect(client.destroy).toHaveBeenCalledOnce();
});
});
describe('Discord project channel provisioning', () => {
it('returns null without a configured guild or visible guild', async () => {
const { plugin } = createPlugin();
await expect(
plugin.createProjectChannel({ id: 'project-1', name: 'Alpha' }),
).resolves.toBeNull();
const { client } = lifecyclePlugin();
const configured = new DiscordPlugin(
{
token: 'unused',
gatewayUrl: 'http://unused',
serviceToken: SERVICE_TOKEN,
guildId: 'missing-guild',
allowedGuildIds: ['guild-001'],
allowedChannelIds: ['channel-001'],
allowedUserIds: ['user-001'],
interactionBindings: [],
},
{ client: client as never },
);
await expect(
configured.createProjectChannel({ id: 'project-1', name: 'Alpha' }),
).resolves.toBeNull();
});
it('creates a normalized Discord project channel', async () => {
const create = vi.fn().mockResolvedValue({ id: 'created-channel-001' });
const { client } = lifecyclePlugin();
client.guilds.cache.set('guild-001', { channels: { create } });
const plugin = new DiscordPlugin(
{
token: 'unused',
gatewayUrl: 'http://unused',
serviceToken: SERVICE_TOKEN,
guildId: 'guild-001',
allowedGuildIds: ['guild-001'],
allowedChannelIds: ['channel-001'],
allowedUserIds: ['user-001'],
interactionBindings: [],
},
{ client: client as never },
);
await expect(
plugin.createProjectChannel({
id: 'project-1',
name: ' Project Alpha! ',
description: 'Alpha workspace',
}),
).resolves.toEqual({ channelId: 'created-channel-001' });
expect(create).toHaveBeenCalledWith(
expect.objectContaining({ name: 'mosaic-project-alpha', topic: 'Alpha workspace' }),
);
await plugin.createProjectChannel({ id: 'project-2', name: 'Beta' });
expect(create).toHaveBeenLastCalledWith(
expect.objectContaining({ topic: 'Mosaic project: Beta' }),
);
});
});
function egressFor(
route: ChannelConversationRouteDto,
content = 'agent response',
): ChannelEgressDto {
return {
correlationId: 'egress-correlation-001',
route,
message: {
id: 'egress-message-001',
channelName: 'discord',
channelId: route.responseTarget.channelId,
senderId: route.logicalAgentId,
senderKind: 'agent',
content,
contentKind: 'markdown',
timestamp: '2026-07-14T12:00:00.000Z',
metadata: {},
},
};
}
describe('official Discord channel routing', () => {
it('normalizes an authorized turn through the shared channel ingress port', async () => {
const receive = vi.fn<(ingress: ChannelIngressDto) => Promise<void>>().mockResolvedValue();
const { internals, emit } = createPlugin('operator', true, { receive });
internals.socket = null;
await internals.handleDiscordMessage(fakeMessage({ content: 'normalized turn' }));
expect(emit).not.toHaveBeenCalled();
expect(receive).toHaveBeenCalledWith(
expect.objectContaining({
operation: 'message.send',
principal: {
channelUserId: 'user-001',
role: 'operator',
mosaicUserId: 'mosaic-user-001',
},
message: expect.objectContaining({
channelName: 'discord',
channelId: 'channel-001',
content: 'normalized turn',
senderKind: 'user',
}),
route: expect.objectContaining({
logicalAgentId: 'Nova',
conversationId: 'Nova:discord:channel-001',
}),
}),
);
});
it('releases a response route when typed ingress rejects', async () => {
const receive = vi.fn().mockRejectedValue(new Error('gateway rejected ingress'));
const { internals } = createPlugin('operator', true, { receive });
await expect(
internals.handleDiscordMessage(fakeMessage({ content: 'rejected typed ingress' })),
).rejects.toThrow('gateway rejected ingress');
expect(internals.conversationRoutes).toHaveLength(0);
});
it('routes an authorized untagged parent-channel message and responds in that channel', async () => {
const { internals, emit } = createPlugin();
const message = fakeMessage({ content: 'channel conversation' });
await internals.handleDiscordMessage(message);
expect(message.startThread).not.toHaveBeenCalled();
const payload = verifyDiscordIngressEnvelope(emittedEnvelope(emit), SERVICE_TOKEN);
expect(payload).toMatchObject({
channelId: 'channel-001',
conversationId: 'Nova:discord:channel-001',
content: 'channel conversation',
});
expect(payload?.threadId).toBeUndefined();
});
it('creates a thread for a mentioned parent-channel message and routes the response there', async () => {
const { internals, emit } = createPlugin();
const message = fakeMessage({
content: '<@bot-001> investigate this topic',
mentioned: true,
createdThreadId: 'thread-created-001',
});
await internals.handleDiscordMessage(message);
expect(message.startThread).toHaveBeenCalledOnce();
const payload = verifyDiscordIngressEnvelope(emittedEnvelope(emit), SERVICE_TOKEN);
expect(payload).toMatchObject({
channelId: 'channel-001',
conversationId: 'Nova:discord:thread-created-001',
content: 'investigate this topic',
threadId: 'thread-created-001',
});
});
it('fetches and reuses an existing thread that is missing from the cache', async () => {
const { internals, emit } = createPlugin();
const message = fakeMessage({
content: '<@bot-001> continue uncached topic',
mentioned: true,
hasThread: true,
fetchedThreadId: 'thread-fetched-001',
});
await internals.handleDiscordMessage(message);
expect(message.startThread).not.toHaveBeenCalled();
expect(verifyDiscordIngressEnvelope(emittedEnvelope(emit), SERVICE_TOKEN)).toMatchObject({
conversationId: 'Nova:discord:thread-fetched-001',
threadId: 'thread-fetched-001',
});
});
it('delivers the agent response to the thread selected by the mentioned turn', async () => {
const { internals } = createPlugin();
const send = vi.fn().mockResolvedValue(undefined);
internals.client = {
user: { id: 'bot-001' },
isReady: (): boolean => true,
channels: {
cache: {
get: (id: string): { send(options: unknown): Promise<void> } | undefined =>
id === 'thread-response-001' ? { send } : undefined,
},
},
};
await internals.handleDiscordMessage(
fakeMessage({
content: '<@bot-001> threaded response',
mentioned: true,
createdThreadId: 'thread-response-001',
}),
);
await internals.sendToDiscord('Nova:discord:thread-response-001', 'agent answer');
expect(send).toHaveBeenCalledWith(
expect.objectContaining({ content: 'agent answer', enforceNonce: true }),
);
});
it('reuses a thread already attached to a mentioned message instead of creating another', async () => {
const { internals, emit } = createPlugin();
const message = fakeMessage({
content: '<@bot-001> continue existing topic',
mentioned: true,
existingThreadId: 'thread-existing-001',
});
await internals.handleDiscordMessage(message);
expect(message.startThread).not.toHaveBeenCalled();
expect(verifyDiscordIngressEnvelope(emittedEnvelope(emit), SERVICE_TOKEN)).toMatchObject({
conversationId: 'Nova:discord:thread-existing-001',
threadId: 'thread-existing-001',
});
});
it('keeps an untagged follow-up inside an authorized thread without nesting threads', async () => {
const { internals, emit } = createPlugin();
const message = fakeMessage({
content: 'thread follow-up',
channelId: 'thread-001',
parentChannelId: 'channel-001',
});
await internals.handleDiscordMessage(message);
expect(message.startThread).not.toHaveBeenCalled();
expect(verifyDiscordIngressEnvelope(emittedEnvelope(emit), SERVICE_TOKEN)).toMatchObject({
channelId: 'channel-001',
conversationId: 'Nova:discord:thread-001',
content: 'thread follow-up',
threadId: 'thread-001',
});
});
it.each([
['guild', { guildId: 'guild-not-allowed' }],
['channel', { channelId: 'channel-not-allowed' }],
['user', { userId: 'user-not-allowed' }],
])(
'rejects an unauthorized %s before thread creation or gateway dispatch',
async (_boundary: string, override: Partial<FakeDiscordMessageOptions>) => {
const { internals, emit } = createPlugin();
const message = fakeMessage({
content: '<@bot-001> unauthorized topic',
mentioned: true,
...override,
});
await internals.handleDiscordMessage(message);
expect(message.startThread).not.toHaveBeenCalled();
expect(emit).not.toHaveBeenCalled();
},
);
it.each([
['parent channel', {}],
['existing thread', { channelId: 'thread-unpaired-001', parentChannelId: 'channel-001' }],
])(
'rejects an allowlisted but unpaired user in %s',
async (_location: string, override: Partial<FakeDiscordMessageOptions>) => {
const { internals, emit } = createPlugin('operator', false);
const message = fakeMessage({ content: 'unpaired message', ...override });
await internals.handleDiscordMessage(message);
expect(message.startThread).not.toHaveBeenCalled();
expect(emit).not.toHaveBeenCalled();
},
);
it('rejects a paired viewer before thread creation or gateway dispatch', async () => {
const { internals, emit } = createPlugin('viewer');
const message = fakeMessage({
content: '<@bot-001> viewer cannot send',
mentioned: true,
});
await internals.handleDiscordMessage(message);
expect(message.startThread).not.toHaveBeenCalled();
expect(emit).not.toHaveBeenCalled();
});
it('uses a normal channel ID rather than its category parent for authorization', async () => {
const { internals, emit } = createPlugin();
const message = fakeMessage({
content: 'message from categorized channel',
parentChannelId: 'category-001',
isThread: false,
});
await internals.handleDiscordMessage(message);
expect(verifyDiscordIngressEnvelope(emittedEnvelope(emit), SERVICE_TOKEN)).toMatchObject({
channelId: 'channel-001',
conversationId: 'Nova:discord:channel-001',
});
});
it.each([
['parent channel', {}],
['existing thread', { channelId: 'thread-attachment-001', parentChannelId: 'channel-001' }],
])(
'preserves an attachment-only turn in an authorized %s',
async (_location: string, override: Partial<FakeDiscordMessageOptions>) => {
const { internals, emit } = createPlugin();
const attachments = new Map<string, FakeDiscordAttachment>([
[
'attachment-001',
{
id: 'attachment-001',
name: 'diagram.png',
url: 'https://cdn.example.invalid/diagram.png',
contentType: 'image/png',
size: 4_096,
},
],
]);
await internals.handleDiscordMessage(fakeMessage({ content: '', attachments, ...override }));
expect(verifyDiscordIngressEnvelope(emittedEnvelope(emit), SERVICE_TOKEN)).toMatchObject({
content: '',
attachments: [
{
id: 'attachment-001',
name: 'diagram.png',
contentType: 'image/png',
sizeBytes: 4_096,
},
],
});
},
);
it('does not dispatch when Discord cannot create the requested thread', async () => {
const { internals, emit } = createPlugin();
const message = fakeMessage({ content: '<@bot-001> new topic', mentioned: true });
message.startThread.mockRejectedValueOnce(new Error('missing thread permission'));
await expect(internals.handleDiscordMessage(message)).rejects.toThrow(
'missing thread permission',
);
expect(emit).not.toHaveBeenCalled();
});
it.each(['/approve', '/stop approval-001'])(
'rejects operator use of privileged command %s before gateway dispatch',
async (command: string) => {
const { internals, emit } = createPlugin('operator');
const message = fakeMessage({ content: `<@bot-001> ${command}`, mentioned: true });
await internals.handleDiscordMessage(message);
expect(message.startThread).not.toHaveBeenCalled();
expect(emit).not.toHaveBeenCalled();
},
);
it('keeps runtime control commands on the current durable session', async () => {
const { internals, emit } = createPlugin('admin');
const message = fakeMessage({ content: '<@bot-001> /approve', mentioned: true });
await internals.handleDiscordMessage(message);
expect(message.startThread).not.toHaveBeenCalled();
expect(emit).toHaveBeenCalledWith('discord:approve', expect.any(Object));
});
it('keeps the stable conversation address independent of a runtime harness', async () => {
const first = createPlugin();
const second = createPlugin();
await first.internals.handleDiscordMessage(
fakeMessage({ id: 'message-claude', content: 'before runtime handoff' }),
);
await second.internals.handleDiscordMessage(
fakeMessage({ id: 'message-pi', content: 'after runtime handoff' }),
);
const firstPayload = verifyDiscordIngressEnvelope(emittedEnvelope(first.emit), SERVICE_TOKEN);
const secondPayload = verifyDiscordIngressEnvelope(emittedEnvelope(second.emit), SERVICE_TOKEN);
expect(firstPayload?.conversationId).toBe('Nova:discord:channel-001');
expect(secondPayload?.conversationId).toBe(firstPayload?.conversationId);
});
it('rate-limits authorized turns before thread creation or dispatch', async () => {
const { internals, emit } = createPlugin('operator', true, undefined, {
messageRateLimitPerMinute: 1,
threadRateLimitPerMinute: 1,
});
const error = vi.spyOn(console, 'error').mockImplementation((): void => undefined);
const first = fakeMessage({ id: 'rate-first', content: 'first turn' });
const second = fakeMessage({
id: 'rate-second',
content: '<@bot-001> second turn',
mentioned: true,
});
await internals.handleDiscordMessage(first);
await internals.handleDiscordMessage(second);
expect(emit).toHaveBeenCalledOnce();
expect(second.startThread).not.toHaveBeenCalled();
expect(error).toHaveBeenCalledWith(expect.stringContaining('Message rate limit reached'));
});
it('applies a stricter mention-thread rate limit before Discord side effects', async () => {
const { internals, emit } = createPlugin('operator', true, undefined, {
messageRateLimitPerMinute: 10,
threadRateLimitPerMinute: 1,
});
vi.spyOn(console, 'error').mockImplementation((): void => undefined);
const first = fakeMessage({ id: 'thread-rate-first', content: 'first topic', mentioned: true });
const second = fakeMessage({
id: 'thread-rate-second',
content: 'second topic',
mentioned: true,
});
await internals.handleDiscordMessage(first);
await internals.handleDiscordMessage(second);
expect(first.startThread).toHaveBeenCalledOnce();
expect(second.startThread).not.toHaveBeenCalled();
expect(emit).toHaveBeenCalledOnce();
});
it('rejects unconfigured egress and handles missing or failed Discord destinations', async () => {
const { plugin, internals } = createPlugin();
const route: ChannelConversationRouteDto = {
bindingId: 'guild-001:channel-001:Nova',
logicalAgentId: 'Nova',
conversationId: 'Nova:discord:channel-001',
channelName: 'discord',
authorizationChannelId: 'channel-001',
responseTarget: { channelId: 'channel-001' },
};
await expect(
plugin.send(
egressFor({
...route,
bindingId: 'forged-binding',
}),
),
).rejects.toMatchObject({ code: 'invalid_route' });
await expect(
plugin.send(
egressFor({
...route,
conversationId: 'Nova:discord:unrelated-conversation',
}),
),
).rejects.toMatchObject({ code: 'invalid_route' });
await expect(
plugin.send({
...egressFor(route),
message: { ...egressFor(route).message, channelId: 'other-channel' },
}),
).rejects.toMatchObject({ code: 'invalid_route' });
const forgedSend = vi.fn().mockResolvedValue(undefined);
internals.client = {
user: { id: 'bot-001' },
isReady: (): boolean => true,
channels: {
cache: {
get: (): { send(options: unknown): Promise<void> } => ({ send: forgedSend }),
},
},
};
await expect(
plugin.send(
egressFor({
...route,
conversationId: 'Nova:discord:forged-channel',
responseTarget: { channelId: 'forged-channel', threadId: 'forged-channel' },
}),
),
).rejects.toMatchObject({ code: 'invalid_route' });
expect(forgedSend).not.toHaveBeenCalled();
internals.client = {
user: { id: 'bot-001' },
isReady: (): boolean => true,
channels: { cache: { get: (): undefined => undefined } },
};
await expect(plugin.send(egressFor(route))).rejects.toMatchObject({
code: 'destination_unavailable',
});
await expect(
internals.sendToDiscord('missing-conversation', 'missing route'),
).rejects.toMatchObject({ code: 'invalid_route' });
vi.useFakeTimers();
const send = vi
.fn()
.mockRejectedValue(Object.assign(new Error('Discord unavailable'), { status: 503 }));
internals.client.channels = {
cache: { get: (): { send(options: unknown): Promise<void> } => ({ send }) },
};
const delivery = plugin.send(egressFor(route));
const rejection = expect(delivery).rejects.toMatchObject({
code: 'delivery_failed',
retryable: true,
});
await vi.runAllTimersAsync();
await rejection;
expect(send).toHaveBeenCalledTimes(3);
expect(new Set(send.mock.calls.map(([options]) => options.nonce)).size).toBe(1);
expect(send).toHaveBeenCalledWith(
expect.objectContaining({ enforceNonce: true, content: 'agent response' }),
);
const permanentSend = vi
.fn()
.mockRejectedValue(Object.assign(new Error('Discord forbidden'), { status: 403 }));
internals.client.channels = {
cache: {
get: (): { send(options: unknown): Promise<void> } => ({ send: permanentSend }),
},
};
await expect(plugin.send(egressFor(route))).rejects.toMatchObject({
code: 'delivery_failed',
retryable: false,
});
expect(permanentSend).toHaveBeenCalledOnce();
});
it('chunks long egress responses at Discord-safe boundaries', async () => {
const { plugin, internals } = createPlugin();
const send = vi.fn().mockResolvedValue(undefined);
internals.client = {
user: { id: 'bot-001' },
isReady: (): boolean => true,
channels: {
cache: { get: (): { send(options: unknown): Promise<void> } => ({ send }) },
},
};
const route: ChannelConversationRouteDto = {
bindingId: 'guild-001:channel-001:Nova',
logicalAgentId: 'Nova',
conversationId: 'Nova:discord:channel-001',
channelName: 'discord',
authorizationChannelId: 'channel-001',
responseTarget: { channelId: 'channel-001' },
};
await plugin.send(egressFor(route, `${'a'.repeat(1_500)}\n${'b'.repeat(1_500)}`));
expect(send).toHaveBeenCalledTimes(2);
});
it('reports channel adapter health without exposing runtime-provider state', async () => {
const { plugin, internals } = createPlugin();
expect(await plugin.health()).toEqual({ status: 'connected' });
internals.socket = { connected: false, emit: vi.fn() };
expect(await plugin.health()).toEqual({ status: 'degraded' });
internals.client = { user: { id: 'bot-001' }, isReady: (): boolean => false };
expect(await plugin.health()).toEqual({ status: 'disconnected' });
internals.socket = { connected: true, emit: vi.fn() };
expect(await plugin.health()).toEqual({ status: 'degraded' });
});
});

View File

@@ -1,10 +1,38 @@
import { createHmac, randomUUID, timingSafeEqual } from 'node:crypto';
import { ChannelType, Client, GatewayIntentBits, type Message as DiscordMessage } from 'discord.js';
import { createHash, createHmac, randomUUID, timingSafeEqual } from 'node:crypto';
import { ChannelDeliveryError } from '@mosaicstack/types';
import type {
ChannelAdapterHealthDto,
ChannelAuthorizedPrincipalDto,
ChannelConversationRouteDto,
ChannelEgressDto,
ChannelEgressPort,
ChannelIngressDto,
ChannelIngressPort,
ChannelMessageDto,
OfficialChannelAdapter,
} from '@mosaicstack/types';
import {
ChannelType,
Client,
GatewayIntentBits,
ThreadAutoArchiveDuration,
type Message as DiscordMessage,
} from 'discord.js';
import { io, type Socket } from 'socket.io-client';
export interface DiscordPluginDependencies {
ingressPort?: ChannelIngressPort;
client?: Client;
socketFactory?: (url: string, options: Parameters<typeof io>[1]) => Socket;
}
export interface DiscordPluginConfig {
token: string;
gatewayUrl: string;
/** Maximum authorized turns per Discord user/channel per minute. */
messageRateLimitPerMinute?: number;
/** Maximum mention-triggered thread routes per Discord user/channel per minute. */
threadRateLimitPerMinute?: number;
/** Shared service credential injected by the approved secret mechanism. */
serviceToken: string;
/** Which guild to bind to (single-guild only for v0.1.0). */
@@ -30,13 +58,23 @@ export interface DiscordInteractionUserBinding {
export type DiscordInteractionPairing = DiscordInteractionRole | DiscordInteractionUserBinding;
export interface DiscordInteractionBinding {
/** Stable logical agent identity used in channel conversation routes. */
instanceId: string;
/** Trusted gateway database agent-config ID selected for this binding. */
agentConfigId: string;
guildId: string;
channelId: string;
/** Pairing roster keyed by Discord user ID. */
pairedUsers: Readonly<Record<string, DiscordInteractionPairing>>;
}
const DEFAULT_MESSAGE_RATE_LIMIT_PER_MINUTE = 30;
const DEFAULT_THREAD_RATE_LIMIT_PER_MINUTE = 5;
const RATE_LIMIT_WINDOW_MS = 60_000;
const DELIVERY_MAX_ATTEMPTS = 3;
const DELIVERY_RETRY_BASE_MS = 50;
const MAX_CONVERSATION_ROUTES = 1_000;
const operationRoles: Readonly<
Record<DiscordInteractionOperation, readonly DiscordInteractionRole[]>
> = {
@@ -90,6 +128,7 @@ export function parseDiscordInteractionBindings(
const candidate = binding as Partial<DiscordInteractionBinding>;
if (
!candidate.instanceId ||
!candidate.agentConfigId ||
!candidate.guildId ||
!candidate.channelId ||
!candidate.pairedUsers ||
@@ -130,6 +169,7 @@ export function parseDiscordInteractionBindings(
) as Record<string, DiscordInteractionPairing>;
return {
instanceId: candidate.instanceId,
agentConfigId: candidate.agentConfigId,
guildId: candidate.guildId,
channelId: candidate.channelId,
pairedUsers,
@@ -154,6 +194,7 @@ export interface DiscordAttachment {
name: string;
url: string;
contentType: string | null;
sizeBytes?: number;
}
export interface DiscordIngressEnvelope {
@@ -229,16 +270,25 @@ export function verifyDiscordIngressEnvelope(
return envelope.payload;
}
export class DiscordPlugin {
export class DiscordPlugin implements OfficialChannelAdapter, ChannelEgressPort {
readonly name = 'discord';
private client: Client;
private socket: Socket | null = null;
/** Map Discord channel ID → Mosaic conversation ID. */
private channelConversations = new Map<string, string>();
/** Bounded last-authorized routes for response-target egress validation. */
private conversationRoutes = new Map<string, ChannelConversationRouteDto>();
/** Track in-flight responses to avoid duplicate streaming. */
private pendingResponses = new Map<string, string>();
private readonly messageRateWindows = new Map<string, number[]>();
private readonly threadRateWindows = new Map<string, number[]>();
constructor(private readonly config: DiscordPluginConfig) {
this.client = new Client({
constructor(
private readonly config: DiscordPluginConfig,
private readonly dependencies: DiscordPluginDependencies = {},
) {
this.client =
dependencies.client ??
new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
@@ -249,7 +299,8 @@ export class DiscordPlugin {
}
async start(): Promise<void> {
this.socket = io(`${this.config.gatewayUrl}/chat`, {
const socketFactory = this.dependencies.socketFactory ?? io;
this.socket = socketFactory(`${this.config.gatewayUrl}/chat`, {
auth: { discordServiceToken: this.config.serviceToken },
transports: ['websocket'],
});
@@ -276,11 +327,13 @@ export class DiscordPlugin {
this.socket.on('agent:end', (data: { conversationId: string }) => {
const text = this.pendingResponses.get(data.conversationId);
if (text) {
this.pendingResponses.delete(data.conversationId);
this.sendToDiscord(data.conversationId, text).catch((err: unknown) => {
if (text) {
this.sendAgentResponse(data.conversationId, text).catch((err: unknown) => {
console.error(`[discord] Error sending response for ${data.conversationId}:`, err);
});
} else {
this.conversationRoutes.delete(data.conversationId);
}
});
@@ -288,15 +341,33 @@ export class DiscordPlugin {
this.pendingResponses.set(data.conversationId, '');
});
this.client.on('messageCreate', (message: DiscordMessage) =>
this.handleDiscordMessage(message),
this.socket.on('error', (data: { conversationId?: unknown }) => {
if (typeof data.conversationId !== 'string') return;
this.pendingResponses.delete(data.conversationId);
this.conversationRoutes.delete(data.conversationId);
});
this.client.on('messageCreate', (message: DiscordMessage) => {
void Promise.resolve(this.handleDiscordMessage(message)).catch((error: unknown): void => {
const errorName = error instanceof Error ? error.name : 'UnknownError';
console.error(
`[discord] Message routing failed. channel=${message.channelId} message=${message.id} error=${errorName}`,
);
});
});
this.client.on('ready', () => {
console.log(`[discord] Bot logged in as ${this.client.user?.tag}`);
});
try {
await this.client.login(this.config.token);
} catch (error: unknown) {
this.socket.disconnect();
this.socket = null;
await this.client.destroy();
throw error;
}
}
async stop(): Promise<void> {
@@ -304,6 +375,14 @@ export class DiscordPlugin {
await this.client.destroy();
}
async health(): Promise<ChannelAdapterHealthDto> {
const discordReady = this.client.isReady();
const gatewayConnected = this.socket?.connected === true;
if (discordReady && gatewayConnected) return { status: 'connected' };
if (discordReady || gatewayConnected) return { status: 'degraded' };
return { status: 'disconnected' };
}
async createProjectChannel(project: {
id: string;
name: string;
@@ -325,77 +404,224 @@ export class DiscordPlugin {
topic: project.description ?? `Mosaic project: ${project.name}`,
});
this.channelConversations.set(channel.id, `discord-${channel.id}`);
// A project channel has no logical-agent conversation until a configured
// binding authorizes a message. Do not seed a legacy channel-only key.
return { channelId: channel.id };
}
private handleDiscordMessage(message: DiscordMessage): void {
if (message.author.bot || !this.client.user) return;
if (!message.guildId || !this.isAllowedMessage(message)) return;
private handleDiscordMessage(message: DiscordMessage): void | Promise<void> {
if (message.author.bot || !this.client.user || !message.guildId) return;
const authorizationChannelId = this.authorizationChannelId(message);
if (!this.isAllowedMessage(message, authorizationChannelId)) return;
const isMention = message.mentions.has(this.client.user);
if (!isMention) return;
const content = isMention
? message.content.replace(new RegExp(`<@!?${this.client.user.id}>`, 'g'), '').trim()
: message.content.trim();
if (!content && message.attachments.size === 0) return;
const operation = this.interactionOperation(content);
const binding = resolveDiscordInteractionBinding(
this.config.interactionBindings ?? [],
message.guildId,
authorizationChannelId,
message.author.id,
operation,
);
// Pairing and operation-specific role checks happen before thread creation
// or any gateway dispatch, including privileged control events.
if (!binding) return;
const content = message.content
.replace(new RegExp(`<@!?${this.client.user.id}>`, 'g'), '')
.trim();
if (!content) return;
if (!this.socket?.connected) {
const rateKey = `${message.guildId}:${authorizationChannelId}:${message.author.id}`;
if (
!this.consumeRateLimit(
this.messageRateWindows,
rateKey,
this.config.messageRateLimitPerMinute ?? DEFAULT_MESSAGE_RATE_LIMIT_PER_MINUTE,
)
) {
console.error(
`[discord] Message rate limit reached. guild=${message.guildId} channel=${authorizationChannelId} user=${message.author.id}`,
);
return;
}
const createThread = isMention && operation === 'send';
if (
createThread &&
!this.consumeRateLimit(
this.threadRateWindows,
rateKey,
this.config.threadRateLimitPerMinute ?? DEFAULT_THREAD_RATE_LIMIT_PER_MINUTE,
)
) {
console.error(
`[discord] Thread rate limit reached. guild=${message.guildId} channel=${authorizationChannelId} user=${message.author.id}`,
);
return;
}
if (!this.dependencies.ingressPort && !this.socket?.connected) {
console.error(
`[discord] Cannot forward message: not connected to gateway. channel=${message.channelId} message=${message.id}`,
);
return;
}
const channelId = message.channelId;
const parentChannelId = 'parentId' in message.channel ? message.channel.parentId : null;
const bindingChannelId = parentChannelId ?? channelId;
const binding = resolveDiscordInteractionBinding(
this.config.interactionBindings ?? [],
message.guildId,
bindingChannelId,
message.author.id,
'send',
// Approval/stop commands act on the current durable session. They remain
// at the current channel/thread target rather than creating a new topic.
const route = this.resolveConversationRoute(
message,
binding,
authorizationChannelId,
createThread,
);
if (!binding) return;
const conversationId =
this.channelConversations.get(channelId) ?? `${binding.instanceId}:discord:${channelId}`;
this.channelConversations.set(channelId, conversationId);
if (route instanceof Promise) {
return route.then((resolved: ChannelConversationRouteDto): void | Promise<void> =>
this.dispatchDiscordIngress(message, content, operation, binding, resolved),
);
}
return this.dispatchDiscordIngress(message, content, operation, binding, route);
}
const envelope = createDiscordIngressEnvelope(
{
correlationId: randomUUID(),
messageId: message.id,
guildId: message.guildId,
channelId: bindingChannelId,
userId: message.author.id,
conversationId,
content,
threadId: parentChannelId ? channelId : undefined,
attachments: Array.from(message.attachments.values()).map((attachment) => ({
private dispatchDiscordIngress(
message: DiscordMessage,
content: string,
operation: 'send' | 'approve' | 'stop',
binding: DiscordInteractionBinding,
route: ChannelConversationRouteDto,
): void | Promise<void> {
const guildId = message.guildId;
if (!guildId) return;
if (operation === 'send') this.rememberConversationRoute(route);
const correlationId = randomUUID();
const ingress: ChannelIngressDto = {
correlationId,
nativeMessageId: message.id,
operation:
operation === 'approve'
? 'approval.create'
: operation === 'stop'
? 'session.stop'
: 'message.send',
principal: this.authorizedPrincipal(binding, message.author.id),
message: this.channelMessage(message, content, route),
route,
};
if (this.dependencies.ingressPort) {
return this.dependencies.ingressPort.receive(ingress).catch((error: unknown) => {
if (operation === 'send') this.conversationRoutes.delete(route.conversationId);
throw error;
});
}
const socket = this.socket;
if (!socket?.connected) {
console.error(
`[discord] Cannot dispatch routed message: gateway disconnected. channel=${message.channelId} message=${message.id}`,
);
return;
}
this.emitDiscordIngress(socket, ingress);
}
private authorizedPrincipal(
binding: DiscordInteractionBinding,
channelUserId: string,
): ChannelAuthorizedPrincipalDto {
const pairing = binding.pairedUsers[channelUserId];
if (!pairing) throw new Error('Authorized Discord pairing is unavailable');
if (typeof pairing === 'string') return { channelUserId, role: pairing };
return {
channelUserId,
role: pairing.role,
...(pairing.mosaicUserId ? { mosaicUserId: pairing.mosaicUserId } : {}),
};
}
private channelMessage(
message: DiscordMessage,
content: string,
route: ChannelConversationRouteDto,
): ChannelMessageDto {
const attachments = Array.from(message.attachments.values()).map((attachment) => ({
id: attachment.id,
name: attachment.name,
url: attachment.url,
contentType: attachment.contentType,
mimeType: attachment.contentType,
sizeBytes: attachment.size,
}));
const firstContentType = attachments[0]?.mimeType;
return {
id: randomUUID(),
channelName: this.name,
channelId: route.responseTarget.channelId,
senderId: message.author.id,
senderKind: 'user',
content,
contentKind:
content.length > 0 ? 'markdown' : firstContentType?.startsWith('image/') ? 'image' : 'file',
timestamp:
message.createdAt instanceof Date
? message.createdAt.toISOString()
: new Date().toISOString(),
...(route.responseTarget.threadId ? { threadId: route.responseTarget.threadId } : {}),
...(attachments.length > 0 ? { attachments } : {}),
metadata: {
channelMessageId: message.id,
guildId: message.guildId ?? '',
},
};
}
private emitDiscordIngress(socket: Socket, ingress: ChannelIngressDto): void {
const envelope = createDiscordIngressEnvelope(
{
correlationId: ingress.correlationId,
messageId: ingress.nativeMessageId,
guildId: String(ingress.message.metadata['guildId'] ?? ''),
channelId: ingress.route.authorizationChannelId,
userId: ingress.principal.channelUserId,
conversationId: ingress.route.conversationId,
content: ingress.message.content,
...(ingress.route.responseTarget.threadId
? { threadId: ingress.route.responseTarget.threadId }
: {}),
attachments: ingress.message.attachments?.map((attachment) => ({
id: attachment.id,
name: attachment.name,
url: attachment.url,
contentType: attachment.mimeType,
...(attachment.sizeBytes !== undefined ? { sizeBytes: attachment.sizeBytes } : {}),
})),
},
this.config.serviceToken,
);
this.socket.emit(
/^\/approve$/i.test(content)
socket.emit(
ingress.operation === 'approval.create'
? 'discord:approve'
: content.startsWith('/stop ')
: ingress.operation === 'session.stop'
? 'discord:stop'
: 'message',
envelope,
);
}
private isAllowedMessage(message: DiscordMessage): boolean {
private authorizationChannelId(message: DiscordMessage): string {
// A normal guild channel can itself have a category parent. Only Discord
// threads inherit authorization from a configured parent text channel.
const channel = message.channel as DiscordMessage['channel'] & {
isThread?: () => boolean;
parentId?: string | null;
};
const isThread =
typeof channel.isThread === 'function'
? channel.isThread()
: channel.parentId !== undefined && channel.parentId !== null;
return isThread && channel.parentId ? channel.parentId : message.channelId;
}
private isAllowedMessage(message: DiscordMessage, authorizationChannelId: string): boolean {
const guildId = message.guildId;
const parentChannelId = 'parentId' in message.channel ? message.channel.parentId : null;
// Threads inherit their authorization boundary from their configured parent.
const authorizationChannelId = parentChannelId ?? message.channelId;
return (
guildId !== null &&
includesId(this.config.allowedGuildIds, guildId) &&
@@ -404,32 +630,283 @@ export class DiscordPlugin {
);
}
private async sendToDiscord(conversationId: string, text: string): Promise<void> {
const channelId = Array.from(this.channelConversations.entries()).find(
([, convId]) => convId === conversationId,
)?.[0];
if (!channelId) {
console.error(`[discord] No channel found for conversation ${conversationId}`);
return;
private resolveConversationRoute(
message: DiscordMessage,
binding: DiscordInteractionBinding,
authorizationChannelId: string,
createThread: boolean,
): ChannelConversationRouteDto | Promise<ChannelConversationRouteDto> {
if (authorizationChannelId !== message.channelId) {
return this.createConversationRoute(
binding,
authorizationChannelId,
message.channelId,
message.channelId,
);
}
if (!createThread) {
return this.createConversationRoute(binding, authorizationChannelId, message.channelId);
}
if (message.hasThread) {
const cachedThread = message.thread;
if (cachedThread) {
return this.createConversationRoute(
binding,
authorizationChannelId,
cachedThread.id,
cachedThread.id,
);
}
if (!('threads' in message.channel)) {
return Promise.reject(new Error('Existing Discord thread manager is unavailable'));
}
return message.channel.threads
.fetch(message.id)
.then((thread): ChannelConversationRouteDto => {
if (!thread) throw new Error('Existing Discord thread is unavailable');
return this.createConversationRoute(
binding,
authorizationChannelId,
thread.id,
thread.id,
);
});
}
return message
.startThread({
name: `Mosaic conversation ${message.id.slice(-8)}`,
autoArchiveDuration: ThreadAutoArchiveDuration.OneHour,
reason: 'Authorized Mosaic mention',
})
.then(
(thread): ChannelConversationRouteDto =>
this.createConversationRoute(binding, authorizationChannelId, thread.id, thread.id),
);
}
private createConversationRoute(
binding: DiscordInteractionBinding,
authorizationChannelId: string,
responseChannelId: string,
threadId?: string,
): ChannelConversationRouteDto {
// Recompute from configuration on every turn so a stale in-memory map can
// never carry a channel-only or differently bound agent identity forward.
const conversationId = `${binding.instanceId}:discord:${responseChannelId}`;
return {
bindingId: `${binding.guildId}:${binding.channelId}:${binding.instanceId}`,
logicalAgentId: binding.instanceId,
conversationId,
channelName: this.name,
authorizationChannelId,
responseTarget: {
channelId: responseChannelId,
...(threadId ? { threadId } : {}),
},
};
}
private consumeRateLimit(
windows: Map<string, number[]>,
key: string,
limit: number,
now = Date.now(),
): boolean {
const active = (windows.get(key) ?? []).filter(
(timestamp: number): boolean => now - timestamp < RATE_LIMIT_WINDOW_MS,
);
if (active.length >= Math.max(1, limit)) {
windows.set(key, active);
return false;
}
active.push(now);
windows.set(key, active);
return true;
}
private interactionOperation(content: string): 'send' | 'approve' | 'stop' {
if (/^\/approve$/i.test(content)) return 'approve';
if (/^\/stop\s+\S+/i.test(content)) return 'stop';
return 'send';
}
async send(egress: ChannelEgressDto): Promise<void> {
if (!this.isConfiguredRoute(egress.route) || !this.isMessageAlignedWithRoute(egress)) {
throw new ChannelDeliveryError(
'invalid_route',
`Discord egress route is not authorized for conversation ${egress.route.conversationId}`,
);
}
const channelId = egress.route.responseTarget.channelId;
const channel = this.client.channels.cache.get(channelId);
if (!channel || !('send' in channel)) {
console.error(
`[discord] Channel ${channelId} not sendable for conversation ${conversationId}`,
throw new ChannelDeliveryError(
'destination_unavailable',
`Discord destination is unavailable for conversation ${egress.route.conversationId}`,
);
return;
}
for (const chunk of this.chunkText(text, 1900)) {
const chunks = this.chunkText(egress.message.content, 1900);
for (const [chunkIndex, chunk] of chunks.entries()) {
await this.sendChunkWithRetry(
channel as {
send(options: { content: string; nonce: string; enforceNonce: true }): Promise<unknown>;
},
chunk,
egress.correlationId,
chunkIndex,
egress.route.conversationId,
);
}
}
private async sendChunkWithRetry(
channel: {
send(options: { content: string; nonce: string; enforceNonce: true }): Promise<unknown>;
},
chunk: string,
correlationId: string,
chunkIndex: number,
conversationId: string,
): Promise<void> {
const nonce = createHash('sha256')
.update(`${correlationId}:${chunkIndex}`)
.digest('hex')
.slice(0, 25);
let lastError: unknown;
for (let attempt = 1; attempt <= DELIVERY_MAX_ATTEMPTS; attempt += 1) {
try {
await (channel as { send: (content: string) => Promise<unknown> }).send(chunk);
} catch (err: unknown) {
console.error(`[discord] Failed to send message to channel ${channelId}:`, err);
await channel.send({ content: chunk, nonce, enforceNonce: true });
return;
} catch (error: unknown) {
lastError = error;
const retryable = this.isTransientDeliveryError(error);
if (!retryable) {
throw new ChannelDeliveryError(
'delivery_failed',
`Discord delivery failed for conversation ${conversationId}`,
false,
{ cause: error },
);
}
if (attempt < DELIVERY_MAX_ATTEMPTS) {
await new Promise<void>((resolve): void => {
setTimeout(resolve, DELIVERY_RETRY_BASE_MS * 2 ** (attempt - 1));
});
}
}
}
throw new ChannelDeliveryError(
'delivery_failed',
`Discord delivery failed for conversation ${conversationId}`,
true,
{ cause: lastError },
);
}
private isTransientDeliveryError(error: unknown): boolean {
if (typeof error !== 'object' || error === null) return false;
const candidate = error as { status?: unknown; code?: unknown };
if (
typeof candidate.status === 'number' &&
(candidate.status === 429 || candidate.status >= 500)
) {
return true;
}
return (
typeof candidate.code === 'string' &&
['ECONNRESET', 'ETIMEDOUT', 'EAI_AGAIN', 'UND_ERR_CONNECT_TIMEOUT'].includes(candidate.code)
);
}
private async sendAgentResponse(conversationId: string, text: string): Promise<void> {
const route = this.conversationRoutes.get(conversationId);
if (!route) {
throw new ChannelDeliveryError(
'invalid_route',
`Discord response route is unavailable for conversation ${conversationId}`,
);
}
try {
await this.send({
correlationId: randomUUID(),
route,
message: {
id: randomUUID(),
channelName: this.name,
channelId: route.responseTarget.channelId,
senderId: route.logicalAgentId,
senderKind: 'agent',
content: text,
contentKind: 'markdown',
timestamp: new Date().toISOString(),
...(route.responseTarget.threadId ? { threadId: route.responseTarget.threadId } : {}),
metadata: {},
},
});
} finally {
this.conversationRoutes.delete(conversationId);
}
}
/** Compatibility wrapper while Socket.IO agent events carry only conversation ID. */
private async sendToDiscord(conversationId: string, text: string): Promise<void> {
await this.sendAgentResponse(conversationId, text);
}
private rememberConversationRoute(route: ChannelConversationRouteDto): void {
if (
!this.conversationRoutes.has(route.conversationId) &&
this.conversationRoutes.size >= MAX_CONVERSATION_ROUTES
) {
throw new ChannelDeliveryError(
'delivery_failed',
'Discord has reached its active response-route limit',
true,
);
}
this.conversationRoutes.set(route.conversationId, route);
}
private isConfiguredRoute(route: ChannelConversationRouteDto): boolean {
if (
route.channelName !== this.name ||
route.conversationId !==
`${route.logicalAgentId}:${this.name}:${route.responseTarget.channelId}`
) {
return false;
}
const bindingMatches = (this.config.interactionBindings ?? []).some(
(binding): boolean =>
route.bindingId === `${binding.guildId}:${binding.channelId}:${binding.instanceId}` &&
route.logicalAgentId === binding.instanceId &&
route.authorizationChannelId === binding.channelId,
);
if (!bindingMatches) return false;
if (
route.responseTarget.channelId === route.authorizationChannelId &&
route.responseTarget.threadId === undefined
) {
return true;
}
const observed = this.conversationRoutes.get(route.conversationId);
return (
observed?.bindingId === route.bindingId &&
observed.logicalAgentId === route.logicalAgentId &&
observed.authorizationChannelId === route.authorizationChannelId &&
observed.responseTarget.channelId === route.responseTarget.channelId &&
observed.responseTarget.threadId === route.responseTarget.threadId
);
}
private isMessageAlignedWithRoute(egress: ChannelEgressDto): boolean {
return (
egress.message.channelName === this.name &&
egress.message.channelId === egress.route.responseTarget.channelId &&
egress.message.threadId === egress.route.responseTarget.threadId
);
}
private chunkText(text: string, maxLength: number): string[] {
if (text.length <= maxLength) return [text];

View File

@@ -4,5 +4,15 @@ export default defineConfig({
test: {
globals: true,
environment: 'node',
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
thresholds: {
lines: 85,
functions: 85,
branches: 85,
statements: 85,
},
},
},
});

41
pnpm-lock.yaml generated
View File

@@ -732,6 +732,9 @@ importers:
plugins/discord:
dependencies:
'@mosaicstack/types':
specifier: workspace:^
version: link:../../packages/types
discord.js:
specifier: ^14.16.0
version: 14.25.1
@@ -739,6 +742,9 @@ importers:
specifier: ^4.8.0
version: 4.8.3
devDependencies:
'@vitest/coverage-v8':
specifier: ^2.0.0
version: 2.1.9(vitest@2.1.9(@types/node@24.12.0)(jsdom@29.0.0(@noble/hashes@2.0.1))(lightningcss@1.31.1))
tsx:
specifier: ^4.0.0
version: 4.21.0
@@ -1169,11 +1175,11 @@ packages:
'@esbuild-kit/core-utils@3.3.2':
resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==}
deprecated: 'Merged into tsx: https://tsx.is'
deprecated: 'Merged into tsx: https://tsx.hirok.io'
'@esbuild-kit/esm-loader@2.6.5':
resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==}
deprecated: 'Merged into tsx: https://tsx.is'
deprecated: 'Merged into tsx: https://tsx.hirok.io'
'@esbuild/aix-ppc64@0.21.5':
resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==}
@@ -2160,47 +2166,57 @@ packages:
'@mariozechner/pi-agent-core@0.63.1':
resolution: {integrity: sha512-h0B20xfs/iEVR2EC4gwiE8hKI1TPeB8REdRJMgV+uXKH7gpeIZ9+s8Dp9nX35ZR0QUjkNey2+ULk2DxQtdg14Q==}
engines: {node: '>=20.0.0'}
deprecated: please use @earendil-works/pi-agent-core instead going forward
'@mariozechner/pi-agent-core@0.63.2':
resolution: {integrity: sha512-9QTS7ylcmoAIWXk0EVpwCCop3fK4NIqTAN8TiRuXvuKYx+wYmUJc+P5+RfehIZhwsy7g9O/rktz0c1YEUBFB0g==}
engines: {node: '>=20.0.0'}
deprecated: please use @earendil-works/pi-agent-core instead going forward
'@mariozechner/pi-agent-core@0.65.0':
resolution: {integrity: sha512-QCDqkgxvCkizCgJOl0aFekT1gURppznzuBIGXS8dXWZMour/xX6YF7chxX56mZ0p0DXkILM1ixf5jXYBfDsP5w==}
engines: {node: '>=20.0.0'}
deprecated: please use @earendil-works/pi-agent-core instead going forward
'@mariozechner/pi-ai@0.63.1':
resolution: {integrity: sha512-wjgwY+yfrFO6a9QdAfjWpH7iSrDean6GsKDDMohNcLCy6PreMxHOZvNM0NwJARL1tZoZovr7ikAQfLGFZbnjsw==}
engines: {node: '>=20.0.0'}
deprecated: please use @earendil-works/pi-ai instead going forward
hasBin: true
'@mariozechner/pi-ai@0.63.2':
resolution: {integrity: sha512-EJNPyzeZeifTJmkD8PPYQmSO4P4h8kFCrhUqU4NvFUkug+GNYr954KlxhYnXH0f77MpdIEpf/O5zdDrYJQyafA==}
engines: {node: '>=20.0.0'}
deprecated: please use @earendil-works/pi-ai instead going forward
hasBin: true
'@mariozechner/pi-ai@0.65.0':
resolution: {integrity: sha512-MsCsCHlHIlBYbg6jB2PJBeCNKbjzVZge7ddBNUJN2gsFY8sdjFh482+GB+r5Ou6k9Fnhi3nO779YDymo5+t89w==}
engines: {node: '>=20.0.0'}
deprecated: please use @earendil-works/pi-ai instead going forward
hasBin: true
'@mariozechner/pi-coding-agent@0.63.1':
resolution: {integrity: sha512-XSoMyLtuMA7ePK1UBWqSJ/BBdtBdJUHY9nbtnNyG6GeW7Gbgd+iqljIuwmAUf8wlYL981UIfYM/WIPQ6t/dIxw==}
engines: {node: '>=20.6.0'}
deprecated: please use @earendil-works/pi-coding-agent instead going forward
hasBin: true
'@mariozechner/pi-coding-agent@0.65.0':
resolution: {integrity: sha512-IEBZ74n17w8NxnG/X2ixErsSYcvLm/h5WKALNbPgPWJZqvafNtJ0GcrCfLCS6RVIq2o+O/a2QwsbSI6bgJ6W/A==}
engines: {node: '>=20.6.0'}
deprecated: please use @earendil-works/pi-coding-agent instead going forward
hasBin: true
'@mariozechner/pi-tui@0.63.1':
resolution: {integrity: sha512-G5p+eh1EPkFCNaaggX6vRrqttnDscK6npgmEOknoCQXZtch8XNgh9Lf3VJ0A2lZXSgR7IntG5dfXHPH/Ki64wA==}
engines: {node: '>=20.0.0'}
deprecated: please use @earendil-works/pi-tui instead going forward
'@mariozechner/pi-tui@0.65.0':
resolution: {integrity: sha512-P5Uuf4x1sTplMNQw8NrC1Hyz0N/tZq9kC6CDRkTT7rZuxZEeXl9uhKvlLEGigdKVOVrWnPE7ip0jrO81POYy3g==}
engines: {node: '>=20.0.0'}
deprecated: please use @earendil-works/pi-tui instead going forward
'@matrix-org/matrix-sdk-crypto-nodejs@0.4.0':
resolution: {integrity: sha512-+qqgpn39XFSbsD0dFjssGO9vHEP7sTyfs8yTpt8vuqWpUpF20QMwpCZi0jpYw7GxjErNTsMshopuo8677DfGEA==}
@@ -3915,6 +3931,7 @@ packages:
'@ungap/structured-clone@1.3.0':
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
deprecated: Potential CWE-502 - Update to 1.3.1 or higher
'@vitest/coverage-v8@2.1.9':
resolution: {integrity: sha512-Z2cOr0ksM00MpEfyVE8KXIYPEcBFxdbLSs56L8PO0QQMxt/6bDj45uQfxoc96v05KW3clk7vvgP0qfDit9DmfQ==}
@@ -4095,6 +4112,7 @@ packages:
basic-ftp@5.2.0:
resolution: {integrity: sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw==}
engines: {node: '>=10.0.0'}
deprecated: Security vulnerability fixed in 5.2.1, please upgrade
better-auth@1.5.5:
resolution: {integrity: sha512-GpVPaV1eqr3mOovKfghJXXk6QvlcVeFbS3z+n+FPDid5rK/2PchnDtiaVCzWyXA9jH2KkirOfl+JhAUvnja0Eg==}
@@ -7087,6 +7105,7 @@ packages:
uuid@9.0.1:
resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==}
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
hasBin: true
validator@13.15.26:
@@ -10977,6 +10996,24 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@vitest/coverage-v8@2.1.9(vitest@2.1.9(@types/node@24.12.0)(jsdom@29.0.0(@noble/hashes@2.0.1))(lightningcss@1.31.1))':
dependencies:
'@ampproject/remapping': 2.3.0
'@bcoe/v8-coverage': 0.2.3
debug: 4.4.3
istanbul-lib-coverage: 3.2.2
istanbul-lib-report: 3.0.1
istanbul-lib-source-maps: 5.0.6
istanbul-reports: 3.2.0
magic-string: 0.30.21
magicast: 0.3.5
std-env: 3.10.0
test-exclude: 7.0.2
tinyrainbow: 1.2.0
vitest: 2.1.9(@types/node@24.12.0)(jsdom@29.0.0(@noble/hashes@2.0.1))(lightningcss@1.31.1)
transitivePeerDependencies:
- supports-color
'@vitest/expect@2.1.9':
dependencies:
'@vitest/spy': 2.1.9