Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 22 additions & 8 deletions cloudflare-gastown/container/plugin/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const TEST_ENV: GastownEnv = {
sessionToken: 'test-jwt-token',
agentId: 'agent-111',
rigId: 'rig-222',
townId: 'town-333',
};

function mockFetch(data: unknown, status = 200) {
Expand Down Expand Up @@ -48,7 +49,9 @@ describe('GastownClient', () => {

expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toBe('https://gastown.example.com/api/rigs/rig-222/agents/agent-111/prime');
expect(url).toBe(
'https://gastown.example.com/api/towns/town-333/rigs/rig-222/agents/agent-111/prime'
);
const headers = new Headers(init.headers);
expect(headers.get('Authorization')).toBe('Bearer test-jwt-token');
expect(headers.get('Content-Type')).toBe('application/json');
Expand Down Expand Up @@ -81,7 +84,7 @@ describe('GastownClient', () => {
expect(result).toEqual(bead);

const [url] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0] as [string];
expect(url).toBe('https://gastown.example.com/api/rigs/rig-222/beads/bead-1');
expect(url).toBe('https://gastown.example.com/api/towns/town-333/rigs/rig-222/beads/bead-1');
});

it('closeBead() sends agent_id in body', async () => {
Expand All @@ -94,7 +97,9 @@ describe('GastownClient', () => {
string,
RequestInit,
];
expect(url).toBe('https://gastown.example.com/api/rigs/rig-222/beads/bead-1/close');
expect(url).toBe(
'https://gastown.example.com/api/towns/town-333/rigs/rig-222/beads/bead-1/close'
);
expect(init.method).toBe('POST');
expect(JSON.parse(init.body as string)).toEqual({ agent_id: 'agent-111' });
});
Expand All @@ -112,7 +117,9 @@ describe('GastownClient', () => {
string,
RequestInit,
];
expect(url).toBe('https://gastown.example.com/api/rigs/rig-222/agents/agent-111/done');
expect(url).toBe(
'https://gastown.example.com/api/towns/town-333/rigs/rig-222/agents/agent-111/done'
);
expect(JSON.parse(init.body as string)).toEqual({
branch: 'feat/test',
pr_url: 'https://github.com/pr/1',
Expand Down Expand Up @@ -145,7 +152,9 @@ describe('GastownClient', () => {
expect(result).toEqual(mail);

const [url] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0] as [string];
expect(url).toBe('https://gastown.example.com/api/rigs/rig-222/agents/agent-111/mail');
expect(url).toBe(
'https://gastown.example.com/api/towns/town-333/rigs/rig-222/agents/agent-111/mail'
);
});

it('writeCheckpoint() posts data to checkpoint endpoint', async () => {
Expand All @@ -157,7 +166,9 @@ describe('GastownClient', () => {
string,
RequestInit,
];
expect(url).toBe('https://gastown.example.com/api/rigs/rig-222/agents/agent-111/checkpoint');
expect(url).toBe(
'https://gastown.example.com/api/towns/town-333/rigs/rig-222/agents/agent-111/checkpoint'
);
expect(JSON.parse(init.body as string)).toEqual({ data: { step: 3, files: ['a.ts'] } });
});

Expand All @@ -172,7 +183,7 @@ describe('GastownClient', () => {
string,
RequestInit,
];
expect(url).toBe('https://gastown.example.com/api/rigs/rig-222/escalations');
expect(url).toBe('https://gastown.example.com/api/towns/town-333/rigs/rig-222/escalations');
expect(JSON.parse(init.body as string)).toEqual({ title: 'blocked', priority: 'high' });
});

Expand Down Expand Up @@ -246,7 +257,9 @@ describe('GastownClient', () => {
// Verify no double slashes in the URL by calling prime
void c.prime();
const [url] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0] as [string];
expect(url).toBe('https://gastown.example.com/api/rigs/rig-222/agents/agent-111/prime');
expect(url).toBe(
'https://gastown.example.com/api/towns/town-333/rigs/rig-222/agents/agent-111/prime'
);
});
});

Expand All @@ -262,6 +275,7 @@ describe('createClientFromEnv', () => {
process.env.GASTOWN_SESSION_TOKEN = 'tok';
process.env.GASTOWN_AGENT_ID = 'agent-1';
process.env.GASTOWN_RIG_ID = 'rig-1';
process.env.GASTOWN_TOWN_ID = 'town-1';

const client = createClientFromEnv();
expect(client).toBeInstanceOf(GastownClient);
Expand Down
57 changes: 48 additions & 9 deletions cloudflare-gastown/container/plugin/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,14 @@ function isApiResponse(

export class GastownClient {
private baseUrl: string;
private containerToken: string | undefined;
private token: string;
private agentId: string;
private rigId: string;
private townId: string;
constructor(env: GastownEnv) {
this.baseUrl = env.apiUrl.replace(/\/+$/, '');
this.containerToken = env.containerToken;
this.token = env.sessionToken;
this.agentId = env.agentId;
this.rigId = env.rigId;
Expand All @@ -50,7 +52,18 @@ export class GastownClient {
// Normalize headers so callers can pass plain objects, Headers instances, or tuples
const headers = new Headers(init?.headers);
headers.set('Content-Type', 'application/json');
headers.set('Authorization', `Bearer ${this.token}`);
// Prefer the live container token from process.env (refreshed by the
// TownDO alarm via POST /refresh-token), then the token captured at
// init, then the legacy per-agent JWT.
const authToken = process.env.GASTOWN_CONTAINER_TOKEN ?? this.containerToken ?? this.token;
headers.set('Authorization', `Bearer ${authToken}`);
// When using a container-scoped JWT, send agent identity headers so
// the auth middleware can populate agentId/rigId on routes that don't
// have :agentId/:rigId params (e.g. /triage/resolve, /mail).
if (process.env.GASTOWN_CONTAINER_TOKEN || this.containerToken) {
headers.set('X-Gastown-Agent-Id', this.agentId);
headers.set('X-Gastown-Rig-Id', this.rigId);
}

let response: Response;
try {
Expand Down Expand Up @@ -193,16 +206,18 @@ export class GastownClient {

/**
* Mayor-scoped client for town-level cross-rig operations.
* Uses `/api/mayor/:townId/tools/*` routes authenticated via townId-scoped JWT.
* Uses `/api/mayor/:townId/tools/*` routes authenticated via container secret or JWT.
*/
export class MayorGastownClient {
private baseUrl: string;
private containerToken: string | undefined;
private token: string;
private agentId: string;
private townId: string;

constructor(env: MayorGastownEnv) {
this.baseUrl = env.apiUrl.replace(/\/+$/, '');
this.containerToken = env.containerToken;
this.token = env.sessionToken;
this.agentId = env.agentId;
this.townId = env.townId;
Expand All @@ -215,7 +230,13 @@ export class MayorGastownClient {
private async request<T>(url: string, init?: RequestInit): Promise<T> {
const headers = new Headers(init?.headers);
headers.set('Content-Type', 'application/json');
headers.set('Authorization', `Bearer ${this.token}`);
// Prefer live container token (refreshed via POST /refresh-token),
// then init-time token, then legacy per-agent JWT.
const authToken = process.env.GASTOWN_CONTAINER_TOKEN ?? this.containerToken ?? this.token;
headers.set('Authorization', `Bearer ${authToken}`);
if (process.env.GASTOWN_CONTAINER_TOKEN || this.containerToken) {
headers.set('X-Gastown-Agent-Id', this.agentId);
}

let response: Response;
try {
Expand Down Expand Up @@ -334,40 +355,58 @@ export class GastownApiError extends Error {

export function createClientFromEnv(): GastownClient {
const apiUrl = process.env.GASTOWN_API_URL;
const containerToken = process.env.GASTOWN_CONTAINER_TOKEN;
const sessionToken = process.env.GASTOWN_SESSION_TOKEN;
Comment thread
jrf0110 marked this conversation as resolved.
const agentId = process.env.GASTOWN_AGENT_ID;
const rigId = process.env.GASTOWN_RIG_ID;
const townId = process.env.GASTOWN_TOWN_ID;

if (!apiUrl || !sessionToken || !agentId || !rigId || !townId) {
// Require either containerToken or sessionToken (prefer containerToken)
const hasAuth = containerToken || sessionToken;
if (!apiUrl || !hasAuth || !agentId || !rigId || !townId) {
const missing = [
!apiUrl && 'GASTOWN_API_URL',
!sessionToken && 'GASTOWN_SESSION_TOKEN',
!hasAuth && 'GASTOWN_CONTAINER_TOKEN or GASTOWN_SESSION_TOKEN',
!agentId && 'GASTOWN_AGENT_ID',
!rigId && 'GASTOWN_RIG_ID',
!townId && 'GASTOWN_TOWN_ID',
].filter(Boolean);
throw new Error(`Missing required Gastown environment variables: ${missing.join(', ')}`);
}

return new GastownClient({ apiUrl, sessionToken, agentId, rigId, townId });
return new GastownClient({
apiUrl,
containerToken: containerToken ?? undefined,
sessionToken: sessionToken ?? '',
agentId,
rigId,
townId,
});
}

export function createMayorClientFromEnv(): MayorGastownClient {
const apiUrl = process.env.GASTOWN_API_URL;
const containerToken = process.env.GASTOWN_CONTAINER_TOKEN;
const sessionToken = process.env.GASTOWN_SESSION_TOKEN;
const agentId = process.env.GASTOWN_AGENT_ID;
const townId = process.env.GASTOWN_TOWN_ID;

if (!apiUrl || !sessionToken || !agentId || !townId) {
const hasAuth = containerToken || sessionToken;
if (!apiUrl || !hasAuth || !agentId || !townId) {
const missing = [
!apiUrl && 'GASTOWN_API_URL',
!sessionToken && 'GASTOWN_SESSION_TOKEN',
!hasAuth && 'GASTOWN_CONTAINER_TOKEN or GASTOWN_SESSION_TOKEN',
!agentId && 'GASTOWN_AGENT_ID',
!townId && 'GASTOWN_TOWN_ID',
].filter(Boolean);
throw new Error(`Missing required mayor environment variables: ${missing.join(', ')}`);
}

return new MayorGastownClient({ apiUrl, sessionToken, agentId, townId });
return new MayorGastownClient({
apiUrl,
containerToken: containerToken ?? undefined,
sessionToken: sessionToken ?? '',
agentId,
townId,
});
}
6 changes: 6 additions & 0 deletions cloudflare-gastown/container/plugin/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,9 @@ export type ConvoyDetail = Convoy & {
// Environment variable config for the plugin (rig-scoped agents)
export type GastownEnv = {
apiUrl: string;
/** Container-scoped JWT (shared by all agents, refreshed by alarm). */
containerToken?: string;
/** Legacy per-agent JWT (8h expiry) — fallback during rollout. */
sessionToken: string;
agentId: string;
rigId: string;
Expand All @@ -128,6 +131,9 @@ export type GastownEnv = {
// Environment variable config for the mayor (town-scoped)
export type MayorGastownEnv = {
apiUrl: string;
/** Container-scoped JWT (shared by all agents, refreshed by alarm). */
containerToken?: string;
/** Legacy per-agent JWT (8h expiry) — fallback during rollout. */
sessionToken: string;
agentId: string;
townId: string;
Expand Down
44 changes: 31 additions & 13 deletions cloudflare-gastown/container/src/agent-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,12 @@ function buildAgentEnv(request: StartAgentRequest): Record<string, string> {
// the request or the container's own environment.
// (KILO_API_URL and KILO_OPENROUTER_BASE are set at the container level
// via TownContainerDO.envVars and inherited through process.env.)
const conditionalKeys = ['GASTOWN_API_URL', 'GASTOWN_SESSION_TOKEN', 'KILOCODE_TOKEN'];
const conditionalKeys = [
'GASTOWN_API_URL',
'GASTOWN_CONTAINER_TOKEN',
'GASTOWN_SESSION_TOKEN',
'KILOCODE_TOKEN',
];
for (const key of conditionalKeys) {
const value = resolveEnv(request, key);
if (value) {
Expand Down Expand Up @@ -328,24 +333,23 @@ async function verifyGitCredentials(
}

/**
* Create a minimal git-initialized workspace for the mayor agent.
* The mayor doesn't need a real repo clone — it's a conversational
* orchestrator that delegates work via tools. But kilo serve requires
* a git repo in the working directory.
* Create a minimal git-initialized workspace for a reasoning-only agent
* (e.g. triage) that doesn't need a real repo clone.
* kilo serve requires a git repo in the working directory, so we init
* a bare local repo with an empty initial commit.
*/
async function createMayorWorkspace(rigId: string): Promise<string> {
async function createLightweightWorkspace(label: string, rigId: string): Promise<string> {
const { mkdir: mkdirAsync } = await import('node:fs/promises');
const { existsSync } = await import('node:fs');
const path = await import('node:path');
// Validate rigId to prevent path traversal (rigId is synthetic: "mayor-<townId>")
// Validate to prevent path traversal
// eslint-disable-next-line no-control-regex
if (!rigId || /\.\.[/\\]|[/\\]\.\.|^\.\.$/.test(rigId) || /[\x00-\x1f]/.test(rigId)) {
throw new Error(`Invalid rigId for mayor workspace: ${rigId}`);
throw new Error(`Invalid rigId for lightweight workspace: ${rigId}`);
}
const dir = path.resolve('/workspace/rigs', rigId, 'mayor-workspace');
const dir = path.resolve('/workspace/rigs', rigId, `${label}-workspace`);
await mkdirAsync(dir, { recursive: true });

// Initialize a bare git repo if not already present
if (!existsSync(`${dir}/.git`)) {
const init = Bun.spawn(['git', 'init'], { cwd: dir, stdout: 'pipe', stderr: 'pipe' });
await init.exited;
Expand All @@ -355,12 +359,22 @@ async function createMayorWorkspace(rigId: string): Promise<string> {
stderr: 'pipe',
});
await commit.exited;
console.log(`Created mayor workspace at ${dir}`);
console.log(`Created ${label} workspace at ${dir}`);
}

return dir;
}

/**
* Create a minimal git-initialized workspace for the mayor agent.
* The mayor doesn't need a real repo clone — it's a conversational
* orchestrator that delegates work via tools. But kilo serve requires
* a git repo in the working directory.
*/
async function createMayorWorkspace(rigId: string): Promise<string> {
return createLightweightWorkspace('mayor', rigId);
}

/**
* Write the mayor's system prompt to AGENTS.md in the workspace.
*
Expand Down Expand Up @@ -415,7 +429,7 @@ async function writeMayorSystemPromptToAgentsMd(

/**
* Run the full agent startup sequence:
* 1. Clone/fetch the rig's git repo (or create minimal workspace for mayor)
* 1. Clone/fetch the rig's git repo (or create minimal workspace for mayor/triage)
* 2. Create an isolated worktree for the agent's branch
* 3. Configure git credentials for push/fetch
* 4. Start a kilo serve instance for the worktree (or reuse existing)
Expand All @@ -425,7 +439,11 @@ export async function runAgent(originalRequest: StartAgentRequest): Promise<Mana
let request = originalRequest;
let workdir: string;

if (request.role === 'mayor') {
if (request.role === 'triage') {
// Triage agents are pure reasoning — no code changes, no git needed.
// Use a lightweight workspace to avoid clone failures feeding the loop.
workdir = await createLightweightWorkspace('triage', request.rigId);
} else if (request.role === 'mayor') {
// Mayor doesn't need a repo clone — just a git-initialized directory
workdir = await createMayorWorkspace(request.rigId);

Expand Down
16 changes: 10 additions & 6 deletions cloudflare-gastown/container/src/completion-reporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,10 @@ export async function reportAgentCompleted(
reason?: string
): Promise<void> {
const apiUrl = agent.gastownApiUrl;
const token = agent.gastownSessionToken;
if (!apiUrl || !token) {
// Prefer live container token (refreshed via POST /refresh-token)
const authToken =
process.env.GASTOWN_CONTAINER_TOKEN ?? agent.gastownContainerToken ?? agent.gastownSessionToken;
if (!apiUrl || !authToken) {
console.warn(
`Cannot report agent ${agent.agentId} completion: no API credentials on agent record`
);
Expand All @@ -29,12 +31,14 @@ export async function reportAgentCompleted(
agent.completionCallbackUrl ??
`${apiUrl}/api/towns/${agent.townId}/rigs/${agent.rigId}/agents/${agent.agentId}/completed`;
try {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
Authorization: `Bearer ${authToken}`,
};

const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
headers,
body: JSON.stringify({ status, reason, agentId: agent.agentId }),
});

Expand Down
Loading
Loading