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
14 changes: 14 additions & 0 deletions .impeccable/config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"detector": {
"ignoreRules": [],
"ignoreFiles": [],
"ignoreValues": [
{
"rule": "overused-font",
"value": "inter",
"createdAt": "2026-08-31T12:14:04.965Z",
"reason": "Project AGENTS/TASKS UI freeze: accepted typography must be preserved; issue #96 permits functional and accessibility fixes, not redesign"
}
]
}
}
25 changes: 25 additions & 0 deletions Dockerfile.live-test
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
ARG NODE_IMAGE=node:22-bookworm-slim
ARG DOCKER_CLI_IMAGE=docker:cli

FROM ${DOCKER_CLI_IMAGE} AS engine-client

FROM ${NODE_IMAGE}
WORKDIR /app

RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates git ripgrep \
&& rm -rf /var/lib/apt/lists/*

COPY --from=engine-client /usr/local/bin/docker /usr/local/bin/docker
COPY package.json package-lock.json tsconfig.base.json ./
COPY apps/server/package.json apps/server/package.json
COPY apps/web/package.json apps/web/package.json
RUN npm ci

COPY --chown=node:node apps ./apps
COPY --chown=node:node scripts ./scripts
RUN mkdir -p /app/.tmp /app/state \
&& chown -R node:node /app/.tmp /app/state

USER node
CMD ["node", "scripts/run-live-vitest.mjs", "runtime"]
26 changes: 19 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -285,20 +285,26 @@ cp deploy/volcengine/terraform.tfvars.example \
deploy/volcengine/terraform.tfvars
export VOLCENGINE_ACCESS_KEY=your-access-key
export VOLCENGINE_SECRET_KEY=your-secret-key
export VOLCENGINE_SSH_PRIVATE_KEY=/absolute/path/to/the-matching-private-key.pem
# Optional when the image does not use Ubuntu's default account:
export VOLCENGINE_SSH_USER=ubuntu
./scripts/deploy-volcengine.sh
```

Set Ark values only in `.env.production` and region/zone/image/instance/key/CIDR/
repository values in `terraform.tfvars`. Provide Volcengine account credentials only
through the current shell; never give account AK/SK values to an Agent Runtime.
After Terraform prints `app_url`, allow cloud-init several minutes and inspect it
with `cloud-init status --wait` and `/var/log/cloud-init-output.log` over SSH.
Terraform provisions only infrastructure and public repository metadata. The deploy
script waits for `cloud-init status --wait`, copies the ignored `.env.production`
over SSH, installs it as root-owned mode `0600`, and then starts the application.
Runtime credentials therefore do not enter Terraform variables, plans, state,
cloud-init, or instance user data.

Use a dedicated test instance. Never commit `.env.production`, Terraform variables
or state, Ark keys, or Volcengine account credentials. The PoC places the Ark key in
Terraform user data/state; production requires managed secrets and encrypted remote
state. `terraform -chdir=deploy/volcengine destroy` removes the ECS instance, system
disk, and Agent workspaces—back up required code and data first.
or state, Ark keys, SSH keys, or Volcengine account credentials. Protect Terraform
state because it still contains infrastructure identifiers and network metadata.
`terraform -chdir=deploy/volcengine destroy` removes the ECS instance, system disk,
and Agent workspaces—back up required code and data first.

## Configuration

Expand Down Expand Up @@ -338,16 +344,22 @@ boundaries.

```bash
npm run check
terraform fmt -check -recursive deploy/volcengine
npm run test:coverage
npm run test:terraform
docker compose config
```

`npm run test:terraform` uses local Terraform when available and otherwise the
pinned `hashicorp/terraform:1.9.8` image. It formats, initializes, and validates a
disposable copy so generated provider files never dirty the checkout.

## Documentation

- [Current task ledger and completion cut](docs/TASKS.md)
- [Shepherd product requirements](docs/PRD.md)
- [Shepherd design and trust boundaries](docs/SHEPHERD.md)
- [Architecture](docs/ARCHITECTURE.md)
- [Release test report](docs/SHEPHERD_TEST_REPORT.md)
- [Local POC](docs/LOCAL_POC.md)
- [Track 1 TechJam brief](docs/TECHJAM.md)
- [Hackathon extension guide](docs/HACKATHON_EXTENSION_GUIDE.md)
Expand Down
53 changes: 53 additions & 0 deletions apps/server/src/agent-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,59 @@ describe("Agent lifecycle", () => {
expect(service.listAgents()).toHaveLength(0);
});

it("refuses Agent admission at durable capacity without creating an orphan workspace", async () => {
const service = await makeService();
const first = await service.createAgent({ name: "Capacity sentinel" });
const internals = service as unknown as { store: JsonStore };
await internals.store.mutate((database) => {
database.agents.push(
...Array.from({ length: 9_999 }, (_, index) => ({
...structuredClone(first),
id: `capacity-agent-${index}`,
name: `Capacity Agent ${index}`,
workspacePath: path.join(path.dirname(first.workspacePath), `capacity-agent-${index}`),
})),
);
});
const workspaceNamesBefore = await readdir(path.dirname(first.workspacePath));

await expect(service.createAgent({ name: "One Agent too many" })).rejects.toMatchObject({
name: "DurableCapacityError",
statusCode: 507,
});
expect(service.listAgents()).toHaveLength(10_000);
expect(await readdir(path.dirname(first.workspacePath))).toEqual(workspaceNamesBefore);
});

it("reserves the final durable Agent slot across concurrent admissions", async () => {
const service = await makeService();
const first = await service.createAgent({ name: "Capacity sentinel" });
const internals = service as unknown as { store: JsonStore };
await internals.store.mutate((database) => {
database.agents.push(
...Array.from({ length: 9_998 }, (_, index) => ({
...structuredClone(first),
id: `concurrent-capacity-agent-${index}`,
name: `Concurrent Capacity Agent ${index}`,
workspacePath: path.join(
path.dirname(first.workspacePath),
`concurrent-capacity-agent-${index}`,
),
})),
);
});

const results = await Promise.allSettled([
service.createAgent({ name: "Final slot contender A" }),
service.createAgent({ name: "Final slot contender B" }),
]);

expect(results.filter(({ status }) => status === "fulfilled")).toHaveLength(1);
expect(results.filter(({ status }) => status === "rejected")).toHaveLength(1);
expect(service.listAgents()).toHaveLength(10_000);
expect(await readdir(path.dirname(first.workspacePath))).toHaveLength(3);
});

it("preserves an Agent and workspace while a private Contract prompt references it", async () => {
const service = await makeService();
const agent = await service.createAgent({
Expand Down
31 changes: 24 additions & 7 deletions apps/server/src/agent-service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { randomUUID } from "node:crypto";
import {
appendWithinDurableCapacity,
assertWithinDurableCapacity,
} from "./collection-capacity.js";
import type { AppConfig } from "./config.js";
import { isArkConfigured, isShepherdModelReviewConfigured } from "./config.js";
import { HttpError, RunCancelledError } from "./errors.js";
Expand Down Expand Up @@ -194,6 +198,7 @@ export class AgentService {
private readonly activeExecutions = new Map<string, Promise<void>>();
private readonly cancellationRequests = new Set<string>();
private readonly deletionReservations = new Set<string>();
private pendingAgentCreations = 0;

constructor(
private readonly config: AppConfig,
Expand Down Expand Up @@ -260,9 +265,21 @@ export class AgentService {
createdAt: timestamp,
updatedAt: timestamp,
};
await this.workspaces.create(agent);
await this.store.mutate((database) => database.agents.push(agent));
return agent;
assertWithinDurableCapacity(
this.store.snapshot().agents,
this.pendingAgentCreations + 1,
"Agent",
);
this.pendingAgentCreations += 1;
try {
await this.workspaces.create(agent);
await this.store.mutate((database) =>
appendWithinDurableCapacity(database.agents, [agent], "Agent"),
);
return agent;
} finally {
this.pendingAgentCreations -= 1;
}
}

async updateAgent(id: string, input: UpdateAgentInput): Promise<Agent> {
Expand Down Expand Up @@ -511,8 +528,8 @@ export class AgentService {
if (storedAgent.currentContractId) {
throw new HttpError(409, "Shepherd is currently using this Agent");
}
database.runs.push(run);
database.messages.push(message);
appendWithinDurableCapacity(database.runs, [run], "Agent run");
appendWithinDurableCapacity(database.messages, [message], "Agent message");
const snapshot = structuredClone(storedAgent);
storedAgent.status = "busy";
storedAgent.lastError = null;
Expand Down Expand Up @@ -592,14 +609,14 @@ export class AgentService {
storedRun.output = result.output;
storedRun.usage = result.usage;
storedRun.completedAt = completedAt;
database.messages.push({
appendWithinDurableCapacity(database.messages, [{
id: randomUUID(),
agentId: agent.id,
runId: run.id,
role: "assistant",
content: result.output,
createdAt: completedAt,
});
}], "Agent message");
agent.status = "ready";
agent.codexThreadId = result.threadId;
agent.lastError = null;
Expand Down
33 changes: 33 additions & 0 deletions apps/server/src/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -785,6 +785,39 @@ describe("HTTP boundary", () => {
await app.close();
});

it("rejects hostile Host and Origin values before tokenless mutation handlers", async () => {
const app = await createApp(
loadConfig({ NODE_ENV: "test" }),
createAgentService(),
createShepherdService(),
);
const route = `/api/agents/${agentId}/start`;
for (const headers of [
{ host: "attacker.example" },
{ host: "localhost", origin: "https://attacker.example" },
{ host: "localhost", origin: "null" },
{ host: "localhost", origin: "http://127.0.0.1" },
]) {
const denied = await app.inject({ method: "POST", url: route, headers });
expect(denied.statusCode, JSON.stringify(headers)).toBe(403);
expect(denied.json()).toEqual({ error: "Local API origin required" });
}

const sameOrigin = await app.inject({
method: "POST",
url: route,
headers: { host: "localhost", origin: "http://localhost" },
});
expect(sameOrigin.statusCode).toBe(200);
const commandLine = await app.inject({
method: "POST",
url: route,
headers: { host: "127.0.0.1:3000" },
});
expect(commandLine.statusCode).toBe(200);
await app.close();
});

it("protects API routes spelled with a percent-encoded prefix", async () => {
// The hook decides whether to authenticate from the RAW request target while
// the router percent-decodes before matching, so any encoded spelling of the
Expand Down
27 changes: 26 additions & 1 deletion apps/server/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import Fastify, { type FastifyInstance } from "fastify";
import { timingSafeEqual } from "node:crypto";
import { fileURLToPath } from "node:url";
import { z } from "zod";
import type { AppConfig } from "./config.js";
import { isLoopbackHost, type AppConfig } from "./config.js";
import { HttpError } from "./errors.js";
import {
agentAuthorityPreset,
Expand Down Expand Up @@ -906,6 +906,31 @@ export async function createApp(
routedPath = "/api/";
routedUrl = "/api/";
}
if (!config.authToken && routedPath.startsWith("/api/")) {
const hostHeader = request.headers.host?.trim().toLowerCase() ?? "";
let requestHostname = "";
try {
requestHostname = new URL(`http://${hostHeader}`).hostname;
} catch {
requestHostname = "";
}
const originHeader = request.headers.origin;
let originAllowed = originHeader === undefined;
if (typeof originHeader === "string") {
try {
const origin = new URL(originHeader);
originAllowed =
origin.protocol === `${request.protocol}:` &&
origin.host.toLowerCase() === hostHeader &&
isLoopbackHost(origin.hostname);
} catch {
originAllowed = false;
}
}
if (!isLoopbackHost(requestHostname) || !originAllowed) {
return reply.code(403).send({ error: "Local API origin required" });
}
}
if (
!config.authToken ||
!routedPath.startsWith("/api/") ||
Expand Down
Loading