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 packages/extension/scripts/amicode_fixture_seed.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,18 @@ export function seedAmicodeSandbox(dir) {
].join("\n"),
);

// --- library (uploaded papers; added_ms = mtime → pinned epochs) -------------
const libraryDir = join(amico, "library");
mkdirSync(libraryDir, { recursive: true });
const paper = (name, content, epoch) => {
const p = join(libraryDir, name);
writeFileSync(p, content);
const t = new Date(epoch);
utimesSync(p, t, t);
};
paper("rydberg-blockade-2024.pdf", "%PDF-1.4 fake rydberg paper\n", "2026-08-02T00:00:00Z");
paper("piccolo-trajectory-2023.pdf", "%PDF-1.4 fake piccolo paper\n", "2026-08-06T00:00:00Z");

// --- stub PATH: `amico` exists (fixed output → deterministic approve fixture),
// `amico-vault` does NOT (forces the CLI-less scanMounts path on both
// sides, so fixtures never depend on a real CLI being installed). ----------
Expand All @@ -304,6 +316,7 @@ export function seedAmicodeSandbox(dir) {
// `amico ledger approve` behave identically regardless of the host machine.
return {
dir,
seededAt: Date.now(),
env: {
AMICODE_PROFILE_FILE: join(amico, "profile.json"),
AMICODE_MOUNTS_FILE: join(amico, "mounts.toml"),
Expand All @@ -313,6 +326,7 @@ export function seedAmicodeSandbox(dir) {
AMICO_VAULTS_ROOT: vaults,
AMICO_LEDGER: join(ledger, "runs.jsonl"),
AMICODE_PROJECT_DIR: dir,
AMICODE_LIBRARY_DIR: libraryDir,
PATH: stubbin,
},
};
Expand Down
24 changes: 23 additions & 1 deletion packages/extension/scripts/record_amicode_fixtures.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,27 @@ const REQUESTS = [
{ method: "GET", path: "/amicode/run-series?run=r20260818-000000Z-4h5i6j", name: "run series — stalled (cold run.log)" },
{ method: "GET", path: "/amicode/run-series?run=r20260812-000000Z-5c6d7e&lab=other", name: "run series — explicit lab" },
{ method: "GET", path: "/amicode/run-series?run=no-such-run", name: "run series — not_found" },
// ── library (slice 4) ──────────────────────────────────────────────────────
{ method: "GET", path: "/amicode/library", name: "library — seeded papers, newest first" },
{
method: "POST",
path: "/amicode/library",
body: { filename: "new paper.pdf", data_b64: Buffer.from("%PDF-1.4 uploaded paper body\n").toString("base64") },
name: "library upload — valid PDF (refreshed listing)",
},
{
method: "POST",
path: "/amicode/library",
body: { filename: "not-a-pdf.txt", data_b64: Buffer.from("plain text\n").toString("base64") },
name: "library upload — bad_filetype refusal",
},
{
method: "POST",
path: "/amicode/library",
body: { filename: "ok.pdf" },
name: "library upload — missing data_b64 refusal",
},
{ method: "GET", path: "/amicode/library", name: "library — post-upload state" },
];

async function main() {
Expand All @@ -139,7 +160,7 @@ async function main() {
console.log(`[record] fork binary: ${binary} (${version})`);

const sandbox = mkdtempSync(join(tmpdir(), "amicode-fixture-"));
const { env: seedEnv } = seedAmicodeSandbox(sandbox);
const { env: seedEnv, seededAt } = seedAmicodeSandbox(sandbox);
const password = "fixture-recording-password";
const port = 4601 + Math.floor(Math.random() * 400);

Expand Down Expand Up @@ -227,6 +248,7 @@ async function main() {
// normalizes these to <SANDBOX> on both sides before comparing.
sandbox: sandbox,
sandboxReal: realpathSync(sandbox),
seededAt: seededAt, // added_ms of route-WRITTEN files (uploads) is wall-clock — normalized at replay
entries,
},
null,
Expand Down
10 changes: 10 additions & 0 deletions packages/extension/src/amicode_service/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
runSeriesResponse,
runStatusResponse,
} from "./problems";
import { libraryBody, saveLibraryFile } from "./library";

export function registerProfileRoutes(server: AmicodeServiceServer): AmicodeServiceServer {
server.add("GET", "/amicode/profile", () => ({ body: profileResponse() }));
Expand Down Expand Up @@ -98,12 +99,21 @@ export function registerProblemRoutes(server: AmicodeServiceServer): AmicodeServ
return server;
}

export function registerLibraryRoutes(server: AmicodeServiceServer): AmicodeServiceServer {
server.add("GET", "/amicode/library", () => ({ body: libraryBody() }));

server.add("POST", "/amicode/library", ({ body }) => ({ body: saveLibraryFile(body) }));

return server;
}

/** The service with every ported slice mounted. The extension wiring slice
* boots this at activation; the contract tests boot it in-process. */
export function createAmicodeService(opts: { password?: string } = {}): AmicodeServiceServer {
const server = new AmicodeServiceServer(opts);
registerProfileRoutes(server);
registerVaultRoutes(server);
registerProblemRoutes(server);
registerLibraryRoutes(server);
return server;
}
83 changes: 83 additions & 0 deletions packages/extension/src/amicode_service/library.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { existsSync, mkdirSync, readdirSync, statSync, writeFileSync } from "fs"
import os from "os"
import path from "path"

// AMICODE: the user's paper library — PDFs uploaded from the home page that
// make Amico smarter about THIS user's work. Files land in ~/.amico/library;
// the amicode extension grants the agent's file tools read access to that dir,
// so "read the paper I just added" works with zero further plumbing. Same
// never-reject discipline as problems.ts: every body is a JSON string.

export function libraryRoot(): string {
const env = process.env.AMICODE_LIBRARY_DIR
if (env && env.trim() !== "") return env
return path.join(os.homedir(), ".amico", "library")
}

export function synthesizeLibrary(code: string, detail: string): string {
return JSON.stringify({ ok: false, papers: [], error: `${code}: ${detail}` })
}

const MAX_BYTES = 30 * 1024 * 1024 // a 30MB PDF is a book; bigger is a mistake

/** Basename-only, conservative charset, single .pdf suffix. */
export function sanitizeFilename(raw: string): string | null {
const base = path.basename(raw).trim()
if (!/\.pdf$/i.test(base)) return null
const clean = base
.slice(0, -4)
.replace(/[^\w.\- ]+/g, "-")
.replace(/\s+/g, " ")
.trim()
.slice(0, 120)
return clean === "" ? null : `${clean}.pdf`
}

export function libraryBody(root: string = libraryRoot()): string {
try {
if (!existsSync(root)) return JSON.stringify({ ok: true, papers: [], error: null })
const papers = readdirSync(root)
.filter((f) => f.toLowerCase().endsWith(".pdf"))
.map((f) => {
const st = statSync(path.join(root, f))
return { name: f, size: st.size, added_ms: Math.round(st.mtimeMs), path: path.join(root, f) }
})
.sort((a, b) => b.added_ms - a.added_ms)
return JSON.stringify({ ok: true, papers, error: null })
} catch (err) {
return synthesizeLibrary("bad_output", String(err))
}
}

/** Save one uploaded paper (JSON body: {filename, data_b64}). Returns the
* refreshed listing on success so the client renders in one round-trip. */
export function saveLibraryFile(rawBody: string, root: string = libraryRoot()): string {
let parsed: { filename?: unknown; data_b64?: unknown }
try {
parsed = JSON.parse(rawBody)
} catch {
return synthesizeLibrary("bad_request", "body must be JSON {filename, data_b64}")
}
if (typeof parsed.filename !== "string" || typeof parsed.data_b64 !== "string")
return synthesizeLibrary("bad_request", "filename and data_b64 are required strings")
const name = sanitizeFilename(parsed.filename)
if (!name) return synthesizeLibrary("bad_filename", "PDFs only; name must survive sanitization")
let bytes: Buffer
try {
bytes = Buffer.from(parsed.data_b64, "base64")
} catch {
return synthesizeLibrary("bad_request", "data_b64 is not valid base64")
}
if (bytes.length === 0) return synthesizeLibrary("bad_request", "empty file")
if (bytes.length > MAX_BYTES) return synthesizeLibrary("too_large", `max ${MAX_BYTES} bytes`)
// magic check: every real PDF opens with %PDF-
if (!bytes.subarray(0, 5).equals(Buffer.from("%PDF-")))
return synthesizeLibrary("bad_filetype", "not a PDF (missing %PDF- header)")
try {
mkdirSync(root, { recursive: true })
writeFileSync(path.join(root, name), bytes)
} catch (err) {
return synthesizeLibrary("write_failed", String(err))
}
return libraryBody(root)
}
27 changes: 22 additions & 5 deletions packages/extension/test/amicode_service_contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import { createAmicodeService } from "../src/amicode_service";
const FIXTURE = fileURLToPath(new URL("./fixtures/amicode/golden.json", import.meta.url));
// Loaded at module scope: the it() cases are GENERATED at collection time,
// before beforeAll runs.
const META: { fork: { tag: string }; sandbox: string; sandboxReal: string; entries: any[] } =
const META: { fork: { tag: string }; sandbox: string; sandboxReal: string; seededAt: number; entries: any[] } =
JSON.parse(readFileSync(FIXTURE, "utf8"));

describe("amicode service — golden-fixture parity with the fork", () => {
Expand All @@ -35,6 +35,7 @@ describe("amicode service — golden-fixture parity with the fork", () => {
let base: string;
let auth: string;
let meta = META;
let testSeededAt = 0;

/** Normalize the sandbox roots (unresolved + realpath forms) to <SANDBOX>. */
const normalize = (text: string, dirs: string[]): string => {
Expand Down Expand Up @@ -63,6 +64,21 @@ describe("amicode service — golden-fixture parity with the fork", () => {
* so the value legitimately differs between recording and replay. It is the
* ONLY wall-clock field; terminal runs pin FINISHED mtimes to fixed epochs
* and stay exactly comparable. */
/** added_ms on a route-WRITTEN paper (an upload) is its write time —
* wall-clock, legitimately different between recording and replay. Seeded
* papers have pinned epochs and compare exactly; anything newer than this
* side's seed time normalizes to <NOW> on both sides. Sort order is
* unaffected (fresh > pinned). */
const normalizeFreshTimestamps = (obj: any, seededAt: number): any => {
if (obj && typeof obj === "object" && Array.isArray(obj.papers)) {
const papers = obj.papers.map((p: any) =>
typeof p?.added_ms === "number" && p.added_ms > seededAt ? { ...p, added_ms: "<NOW>" } : p,
);
return { ...obj, papers };
}
return obj;
};

const normalizeWallClock = (obj: any): any => {
if (
obj &&
Expand All @@ -78,9 +94,9 @@ describe("amicode service — golden-fixture parity with the fork", () => {
};

beforeAll(async () => {
meta = JSON.parse(readFileSync(FIXTURE, "utf8"));
sandbox = mkdtempSync(join(tmpdir(), "amicode-parity-"));
const { env } = seedAmicodeSandbox(sandbox);
const { env, seededAt } = seedAmicodeSandbox(sandbox);
testSeededAt = seededAt;
// Redirect every state root to the sandbox (the port resolves these at
// call time). PATH is pinned to the seeded stub dir on BOTH sides (the
// recorder pins it for the fork spawn) so `amico` is the stub and
Expand Down Expand Up @@ -136,8 +152,9 @@ describe("amicode service — golden-fixture parity with the fork", () => {
const received = normalize(await r.text(), [sandbox, realpathSync(sandbox)]);
// Deep-equal on parsed JSON: key order in the serialized body is the
// port's business, structure and values are the contract.
const canon = (o: any) => normalizeListOrder(normalizeWallClock(o));
expect(canon(JSON.parse(received))).toEqual(canon(JSON.parse(expected)));
const canon = (o: any, seededAt: number) =>
normalizeListOrder(normalizeWallClock(normalizeFreshTimestamps(o, seededAt)));
expect(canon(JSON.parse(received), testSeededAt)).toEqual(canon(JSON.parse(expected), meta.seededAt));
});
}

Expand Down
Loading
Loading