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
5 changes: 4 additions & 1 deletion packages/sdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@
"version": "0.0.0",
"private": true,
"type": "module",
"exports": { ".": "./src/index.ts" },
"exports": {
".": "./src/index.ts",
"./testing": "./src/testing.ts"
},
"scripts": {
"build": "echo 'build: not yet implemented'",
"typecheck": "tsc --noEmit",
Expand Down
99 changes: 99 additions & 0 deletions packages/sdk/src/atomic.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/**
* Atomic-write helpers — store-layout.md §7.1: every write under
* `~/.agents/` is staged to a sibling temp path, then renamed. Readers
* see the complete old file or the complete new one, never a partial.
*/

import { FsPort } from "./ports.js";
import { dirname, join } from "./path.js";

const nonce = (): string =>
Math.random().toString(36).slice(2, 10) + Date.now().toString(36);

/** Write a file atomically: sibling temp + rename. */
export const atomicWriteFile = async (
fs: FsPort,
path: string,
data: Uint8Array,
): Promise<void> => {
const tmp = join(dirname(path), `.${path.slice(path.lastIndexOf("/") + 1)}.tmp-${nonce()}`);
try {
await fs.writeFile(tmp, data);
await fs.rename(tmp, path);
} catch (e) {
await fs.remove(tmp, { recursive: true }).catch(() => undefined);
throw e;
}
};

/**
* Move a staged directory into place. `dest` must not exist — callers
* use `swapDirectory` for the update path.
*/
export const renameIntoPlace = async (
fs: FsPort,
staged: string,
dest: string,
): Promise<void> => {
await fs.rename(staged, dest);
};

/**
* Atomic-ish package swap (trust.md §3.3): the old tree is renamed to a
* trash sibling first, the new tree renamed in, then the trash removed.
* Same-directory renames keep each step atomic; the brief gap between
* them is why callers hold `.lock`.
*/
export const swapDirectory = async (
fs: FsPort,
staged: string,
dest: string,
): Promise<void> => {
const trash = `${dest}.old-${nonce()}`;
const st = await fs.stat(dest);
if (st !== null) await fs.rename(dest, trash);
try {
await fs.rename(staged, dest);
} catch (e) {
if (st !== null) await fs.rename(trash, dest).catch(() => undefined);
throw e;
}
if (st !== null) await fs.remove(trash, { recursive: true });
};

/** Copy a directory tree recursively through the port (local sources). */
export const copyTree = async (
fs: FsPort,
from: string,
to: string,
): Promise<void> => {
const st = await fs.stat(from);
if (st === null) return;
if (st.type === "directory") {
await fs.mkdir(to);
for (const entry of await fs.readDir(from)) {
await copyTree(fs, join(from, entry.name), join(to, entry.name));
}
return;
}
if (st.type === "file") {
await fs.writeFile(to, await fs.readFile(from));
return;
}
if (st.type === "symlink") {
const target = await fs.readlink(from);
if (fs.symlink !== undefined) {
await fs.symlink(target, to);
return;
}
// Ports without a symlink primitive dereference, but only when the
// target stays inside the copied tree — containment (§6.3) still holds.
const resolved = target.startsWith("/")
? target
: join(dirname(from), target);
const data = await fs.readFile(resolved).catch(() => null);
if (data === null)
throw new Error(`cannot copy dangling/unreadable symlink ${from} -> ${target}`);
await fs.writeFile(to, data);
}
};
45 changes: 45 additions & 0 deletions packages/sdk/src/audit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/**
* Audit log — `harness/audit.log`, append-only JSONL (trust.md §6).
* One object per line; writers must already hold `harness/.lock`.
* Records never carry secrets (§6.3) — `details` is call-site data.
*/

import { FsPort } from "./ports.js";
import type { AuditRecord } from "./types.js";

const encoder = new TextEncoder();
const decoder = new TextDecoder();

export const appendAudit = async (
fs: FsPort,
auditPath: string,
record: AuditRecord,
): Promise<void> => {
const line = `${JSON.stringify(record)}\n`;
await fs.appendFile(auditPath, encoder.encode(line));
};

/** Read the log, tolerating partial lines from crashed writers (§6.1). */
export const readAudit = async (
fs: FsPort,
auditPath: string,
): Promise<{ records: AuditRecord[]; skipped: number }> => {
let text: string;
try {
text = decoder.decode(await fs.readFile(auditPath));
} catch {
return { records: [], skipped: 0 };
}
const records: AuditRecord[] = [];
let skipped = 0;
for (const line of text.split("\n")) {
const trimmed = line.trim();
if (trimmed === "") continue;
try {
records.push(JSON.parse(trimmed) as AuditRecord);
} catch {
skipped++;
}
}
return { records, skipped };
};
153 changes: 153 additions & 0 deletions packages/sdk/src/bridge.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import { describe, expect, it } from "vitest";
import { createBridgeSession, handleBridgeRequest, type JsonRpcRequest } from "./bridge.js";
import { createStore, type Store } from "./store.js";
import { createTestPorts } from "./testing.js";

const ROOT = "/agents";

const makeStore = async (): Promise<Store> => {
const ports = createTestPorts();
ports.fs.putFile(
"/src/plug/plugin.json",
JSON.stringify({
$schema: "x",
name: "plug",
version: "1.0.0",
extensions: { "dev.anyharness": { namespaceVersion: 1, capabilities: ["storage.fs"] } },
}),
);
ports.fs.putFile("/src/plug/skills/helper/SKILL.md", "---\nname: helper\ndescription: d\n---\nBody.");
ports.fs.putFile("/src/plug/dev.anyharness/commands/lint.md", "---\ndescription: lint\n---\nLint it.");
const store = createStore(ROOT, ports);
await store.install("/src/plug");
return store;
};

const req = (method: string, params?: Record<string, unknown>, id: string | number = "r1"): JsonRpcRequest => ({
jsonrpc: "2.0",
id,
method,
params,
});

const negotiate = async (store: Store, session?: ReturnType<typeof createBridgeSession>) =>
handleBridgeRequest(store, req("capabilities.negotiate", {
protocol: { supported: ["0.1", "0.9"] },
client: { name: "test-harness", version: "1.0" },
capabilities: {
kinds: ["skill", "command", "hook", "mcp", "plugin"],
hookEvents: ["tool.before"],
slots: { storage: "fs", secrets: "host", exec: true, skills: "read-write", mcp: "external" },
},
}, "neg"), session);

describe("handleBridgeRequest", () => {
it("handshake gate: non-negotiate first → -32002", async () => {
const store = await makeStore();
const res = await handleBridgeRequest(store, req("extensions.list"));
expect(res.error?.code).toBe(-32002);
});

it("negotiate picks common protocol + returns granted caps; second negotiate → -32602", async () => {
const store = await makeStore();
const res = await negotiate(store);
expect(res.error).toBeUndefined();
const result = res.result as Record<string, unknown>;
expect((result["protocol"] as { version: string }).version).toBe("0.1");
const caps = result["capabilities"] as { slots: Record<string, unknown> };
expect(caps.slots["storage"]).toBe("fs");
expect(caps.slots["mcp"]).toBe("external");

const again = await handleBridgeRequest(store, req("capabilities.negotiate", {
protocol: { supported: ["0.1"] },
client: { name: "x", version: "1" },
capabilities: {},
}));
expect(again.error?.code).toBe(-32602);
});

it("version-mismatch → -32001 with supported list", async () => {
const store = await makeStore();
const res = await handleBridgeRequest(store, req("capabilities.negotiate", {
protocol: { supported: ["9.9"] },
client: { name: "x", version: "1" },
capabilities: {},
}));
expect(res.error?.code).toBe(-32001);
expect(res.error?.data?.["supported"]).toContain("0.1");
});

it("extensions.list filters + extensions.get returns document", async () => {
const store = await makeStore();
await negotiate(store);
const list = await handleBridgeRequest(store, req("extensions.list", {}));
const exts = (list.result as { extensions: { id: string }[] }).extensions;
expect(exts.map((e) => e.id)).toContain("plug@1.0.0");

const get = await handleBridgeRequest(store, req("extensions.get", { id: "plug@1.0.0" }));
const doc = (get.result as { document: Record<string, unknown> }).document;
expect(doc["name"]).toBe("plug");
expect(get.error).toBeUndefined();

const missing = await handleBridgeRequest(store, req("extensions.get", { id: "nope@0.0.0" }));
expect(missing.error?.code).toBe(-32004);
});

it("skills.materialize → store target copies skill to shared root", async () => {
const store = await makeStore();
await negotiate(store);
const res = await handleBridgeRequest(store, req("skills.materialize", { extension: "plug@1.0.0", target: "store" }));
expect(res.error).toBeUndefined();
const skills = (res.result as { skills: { name: string; materializedTo?: string }[] }).skills;
expect(skills.map((s) => s.name)).toContain("helper");
expect((await store.ports.fs.stat(`${ROOT}/skills/helper/SKILL.md`))?.type).toBe("file");
});

it("commands.resolve returns expansion + argv", async () => {
const store = await makeStore();
await negotiate(store);
const res = await handleBridgeRequest(store, req("commands.resolve", { text: "/lint --fix" }));
expect(res.error).toBeUndefined();
const r = res.result as { expansion: { prompt: string }; command: { argv: string[] } };
expect(r.expansion.prompt).toContain("Lint it.");
expect(r.command.argv).toEqual(["--fix"]);
});

it("tools.call without mcp:managed → capability-unsupported", async () => {
const store = await makeStore();
await negotiate(store);
const res = await handleBridgeRequest(store, req("tools.call", { server: "s", tool: "t", arguments: {} }));
expect(res.error?.code).toBe(-32003);
});

it("unknown method → -32601; notifications not answered (placeholder ok)", async () => {
const store = await makeStore();
const res = await handleBridgeRequest(store, req("nope.method", {}));
expect(res.error?.code).toBe(-32601);
const notif = await handleBridgeRequest(store, { jsonrpc: "2.0", method: "events.notify", params: { kind: "extensions.changed" } });
expect(notif.result).toBeTruthy();
});

it("hooks.invoke runs matching hook entries and merges status", async () => {
const ports = createTestPorts();
ports.fs.putFile(
"/src/hk/plugin.json",
JSON.stringify({ $schema: "x", name: "hk", version: "1.0.0", extensions: { "dev.anyharness": { namespaceVersion: 1 } } }),
);
ports.fs.putFile("/src/hk/dev.anyharness/hooks.json", JSON.stringify({ hooks: { "tool.before": [{ command: "hook-runner" }] } }));
// Approve hook exec for the daemon actor: nonInteractive=allow.
ports.fs.putFile(`${ROOT}/harness/config.toml`, `[policy.exec]\nnonInteractive = "allow"\n`);
ports.exec.handler.set("hook-runner", () => ({ code: 0, stdout: JSON.stringify({ status: "modify", output: { patched: true } }), stderr: "" }));
const store = createStore(ROOT, ports);
await store.install("/src/hk");
const session = createBridgeSession(store);
await negotiate(store, session);
const res = await handleBridgeRequest(store, req("hooks.invoke", {
event: "tool.before",
input: { tool: "x" },
}), session);
expect(res.error).toBeUndefined();
expect((res.result as { status: string }).status).toBe("modify");
expect(ports.exec.calls.some((c) => c.cmd === "hook-runner")).toBe(true);
});
});
Loading
Loading