Skip to content
Open

. #115

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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -132,4 +132,4 @@ jobs:
- name: Stop API
if: always()
run: |
[[ -n "${API_PID:-}" ]] && kill "$API_PID" 2>/dev/null || true
[[ -n "${API_PID:-}" ]] && kill "$API_PID" 2>/dev/null || true
1 change: 0 additions & 1 deletion .prettierrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,4 @@
"singleQuote": false,
"trailingComma": "none",
"printWidth": 100

}
2 changes: 1 addition & 1 deletion apps/agent-client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,4 @@
"typescript": "^5.8.3",
"vitest": "^3.2.4"
}
}
}
12 changes: 6 additions & 6 deletions apps/agent-client/src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,15 +86,15 @@ describe("buildReceipt", () => {
provider: "search.basic",
term: "test query",
price: 0.01,
traceId: "trace_abc123",
traceId: "trace_abc123"
});

expect(receipt).toEqual({
command: "search",
provider: "search.basic",
input: "test query",
price: 0.01,
traceId: "trace_abc123",
traceId: "trace_abc123"
});
});

Expand All @@ -103,15 +103,15 @@ describe("buildReceipt", () => {
const receipt = buildReceipt({
mode: "scrape",
provider: "scrape.page",
term: "https://example.com",
term: "https://example.com"
});

expect(receipt).toEqual({
command: "scrape",
provider: "scrape.page",
input: "https://example.com",
price: null,
traceId: null,
traceId: null
});
});

Expand All @@ -123,7 +123,7 @@ describe("buildReceipt", () => {
provider: "search.basic",
term: long,
price: 0.05,
traceId: "trace_xyz",
traceId: "trace_xyz"
});

expect(receipt.input).toBe("a".repeat(47) + "...");
Expand All @@ -140,7 +140,7 @@ describe("formatSummary", () => {
asset: "USDC",
traceId: "trace-abc-123",
evidenceId: "ev-xyz-789",
latencyMs: 342,
latencyMs: 342
};

it("includes mode and provider", () => {
Expand Down
28 changes: 14 additions & 14 deletions apps/agent-client/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,22 +17,20 @@
/** Formats the post-query summary table. Pure function — safe to unit-test directly. */
export function formatSummary(input: SummaryInput): string {
const rows: [string, string][] = [
["Mode", input.mode],
["Provider", input.provider],
["Status", String(input.status)],
["Client", input.isDemoMode ? "demo" : "real"],
["Mode", input.mode],
["Provider", input.provider],
["Status", String(input.status)],
["Client", input.isDemoMode ? "demo" : "real"],
["Price (USD)", input.priceUsd != null ? String(input.priceUsd) : "n/a"],
["Asset", input.asset ?? "n/a"],
["Trace ID", input.traceId ?? "unavailable"],
["Evidence ID", input.evidenceId ?? "unavailable"],
["Asset", input.asset ?? "n/a"],
["Trace ID", input.traceId ?? "unavailable"],
["Evidence ID", input.evidenceId ?? "unavailable"]
];
if (input.latencyMs != null) {
rows.push(["Latency", `${input.latencyMs}ms`]);
}
const labelWidth = Math.max(...rows.map(([label]) => label.length));
const body = rows
.map(([label, value]) => ` ${label.padEnd(labelWidth)} ${value}`)
.join("\n");
const body = rows.map(([label, value]) => ` ${label.padEnd(labelWidth)} ${value}`).join("\n");
const divider = "=".repeat(labelWidth + 4 + 20);
return `\n=== Query402 Paid Query Summary ===\n${body}\n${divider}`;
}
Expand Down Expand Up @@ -76,7 +74,7 @@
provider: input.provider,
input: redactInput(input.term),
price: input.price ?? null,
traceId: input.traceId ?? null,
traceId: input.traceId ?? null
};
}

Expand All @@ -84,7 +82,7 @@
const args = process.argv.slice(2);
const modeArg = args[0];
const term = args[1];
const receiptMode = hasFlag("--receipt", args) || hasFlag("--json", args);

Check warning on line 85 in apps/agent-client/src/cli.ts

View workflow job for this annotation

GitHub Actions / quality

'receiptMode' is assigned a value but never used. Allowed unused vars must match /^_/u

if (!modeArg || !["search", "news", "scrape"].includes(modeArg)) {
usage();
Expand Down Expand Up @@ -117,8 +115,10 @@
const latencyMs = Date.now() - start;

const payload = result.body as Record<string, unknown>;
const resultBlock = (payload?.result ?? (payload?.body as Record<string, unknown>)?.result) as Record<string, unknown> | undefined;
const evidenceBlock = (payload?.payment as Record<string, unknown>)?.evidence as Record<string, unknown> | undefined;
const resultBlock = (payload?.result ?? (payload?.body as Record<string, unknown>)?.result) as
Record<string, unknown> | undefined;
const evidenceBlock = (payload?.payment as Record<string, unknown>)?.evidence as
Record<string, unknown> | undefined;

console.log(
formatSummary({
Expand All @@ -130,7 +130,7 @@
asset: (evidenceBlock?.proofLinks as Record<string, string> | undefined)?.asset,
traceId: resultBlock?.traceId as string | undefined,
evidenceId: (evidenceBlock?.id ?? evidenceBlock?.evidenceId) as string | undefined,
latencyMs,
latencyMs
})
);
}
Expand Down
98 changes: 50 additions & 48 deletions apps/agent-client/src/transcript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,7 @@ const OUT_DIR = path.resolve(__dirname, "../../../transcript");
// Guard: refuse to run outside DEMO_MODE
// ---------------------------------------------------------------------------
if (config.DEMO_MODE !== "true") {
console.error(
"ERROR: Set DEMO_MODE=true to generate a transcript without live credentials."
);
console.error("ERROR: Set DEMO_MODE=true to generate a transcript without live credentials.");
process.exit(1);
}

Expand All @@ -49,10 +47,10 @@ const API_BASE = config.API_BASE_URL.replace(/\/$/, "");
// Secret redaction
// ---------------------------------------------------------------------------
const SECRET_PATTERNS: RegExp[] = [
/S[A-Z0-9]{55}/g, // Stellar secret key (starts with S, 56 chars)
/Bearer\s+\S+/gi, // Bearer tokens
/x-payment:\s*\S+/gi, // raw payment header value
/X402-Payment:\s*\S+/gi,
/S[A-Z0-9]{55}/g, // Stellar secret key (starts with S, 56 chars)
/Bearer\s+\S+/gi, // Bearer tokens
/x-payment:\s*\S+/gi, // raw payment header value
/X402-Payment:\s*\S+/gi
];
const REDACTED = "[REDACTED]";

Expand All @@ -61,7 +59,7 @@ const SENSITIVE_HEADER_KEYS = new Set([
"x402-payment",
"authorization",
"x-api-key",
"payment-response", // raw tx ID; replaced with presence flag below
"payment-response" // raw tx ID; replaced with presence flag below
]);

const SENSITIVE_OBJ_KEYS = new Set([
Expand All @@ -71,7 +69,7 @@ const SENSITIVE_OBJ_KEYS = new Set([
"private_key",
"privatekey",
"api_key",
"apikey",
"apikey"
]);

function redact(value: unknown): unknown {
Expand Down Expand Up @@ -117,15 +115,21 @@ interface RawResult {

function httpGet(url: string): Promise<RawResult> {
return new Promise((resolve, reject) => {
http.get(url, (res) => {
let raw = "";
res.on("data", (chunk: Buffer) => (raw += chunk.toString()));
res.on("end", () => {
let body: unknown;
try { body = JSON.parse(raw); } catch { body = raw; }
resolve({ status: res.statusCode ?? 0, headers: res.headers, body });
});
}).on("error", reject);
http
.get(url, (res) => {
let raw = "";
res.on("data", (chunk: Buffer) => (raw += chunk.toString()));
res.on("end", () => {
let body: unknown;
try {
body = JSON.parse(raw);
} catch {
body = raw;
}
resolve({ status: res.statusCode ?? 0, headers: res.headers, body });
});
})
.on("error", reject);
});
}

Expand Down Expand Up @@ -167,17 +171,15 @@ async function step1Health(): Promise<Step> {
responseHeaders: safeHeaders(r.headers),
body: redact(r.body),
note:
r.status === 200
? "API is healthy and ready."
: "API responded but may not be fully ready.",
r.status === 200 ? "API is healthy and ready." : "API responded but may not be fully ready."
};
} catch (err) {
return {
step: "1_health_check",
timestamp,
status: "n/a",
error: `Could not reach ${API_BASE}/health — is the API running? (${err})`,
note: "Transcript is still written for CI evidence purposes.",
note: "Transcript is still written for CI evidence purposes."
};
}
}
Expand All @@ -192,14 +194,14 @@ async function step2Catalog(): Promise<Step> {
status: r.status,
responseHeaders: safeHeaders(r.headers),
body: redact(r.body),
note: "Available search/news/scrape providers with per-request pricing.",
note: "Available search/news/scrape providers with per-request pricing."
};
} catch (err) {
return {
step: "2_provider_catalog",
timestamp,
status: "n/a",
error: String(err),
error: String(err)
};
}
}
Expand All @@ -210,9 +212,9 @@ async function step2Catalog(): Promise<Step> {
*/
async function step3PaidQueries(): Promise<Step[]> {
const queries: Array<Parameters<typeof runPaidQuery>[0]> = [
{ mode: "search", provider: "search.pro", query: "latest stellar x402 updates" },
{ mode: "news", provider: "news.deep", query: "stablecoin micropayments" },
{ mode: "scrape", provider: "scrape.extract", url: "https://developers.stellar.org" },
{ mode: "search", provider: "search.pro", query: "latest stellar x402 updates" },
{ mode: "news", provider: "news.deep", query: "stablecoin micropayments" },
{ mode: "scrape", provider: "scrape.extract", url: "https://developers.stellar.org" }
];

const steps: Step[] = [];
Expand All @@ -225,24 +227,24 @@ async function step3PaidQueries(): Promise<Step[]> {
try {
const response = await runPaidQuery(q);
const payload = response.body as Record<string, unknown> | undefined;
const result = payload?.result as Record<string, unknown> | undefined;
const result = payload?.result as Record<string, unknown> | undefined;

steps.push({
step: label,
timestamp,
status: response.status,
body: redact({
provider: q.provider,
endpoint: response.endpoint,
provider: q.provider,
endpoint: response.endpoint,
// Never write the raw payment-response header value; record presence only
payment_response_present: Boolean(response.paymentResponse),
price_usd: result?.priceUsd ?? "n/a",
items_returned: Array.isArray(result?.items) ? result.items.length : 0,
result_body: payload,
price_usd: result?.priceUsd ?? "n/a",
items_returned: Array.isArray(result?.items) ? result.items.length : 0,
result_body: payload
}),
note:
"DEMO_MODE=true — payment header contains a placeholder tx ID; " +
"no real Stellar transaction is submitted or settled.",
"no real Stellar transaction is submitted or settled."
});
} catch (err) {
steps.push({ step: label, timestamp, status: "n/a", error: String(err) });
Expand All @@ -254,21 +256,21 @@ async function step3PaidQueries(): Promise<Step[]> {

function step4Metadata(paidSteps: Step[]): Step {
const first = paidSteps.find((s) => s.status !== "n/a");
const body = first?.body as Record<string, unknown> | undefined;
const body = first?.body as Record<string, unknown> | undefined;
return {
step: "4_response_metadata",
timestamp: new Date().toISOString(),
status: "n/a",
body: {
provider: body?.provider ?? "search.pro",
price_usd: body?.price_usd ?? "n/a",
provider: body?.provider ?? "search.pro",
price_usd: body?.price_usd ?? "n/a",
settlement_network: "stellar:testnet",
payment_status: "DEMO — no real Stellar settlement",
payment_status: "DEMO — no real Stellar settlement",
note:
"In production this section contains the facilitator-signed " +
"payment-response header. Here it is intentionally omitted.",
"payment-response header. Here it is intentionally omitted."
},
note: "Synthesised from paid-query responses. No secrets included.",
note: "Synthesised from paid-query responses. No secrets included."
};
}

Expand All @@ -282,14 +284,14 @@ async function step5Analytics(): Promise<Step> {
status: r.status,
responseHeaders: safeHeaders(r.headers),
body: redact(r.body),
note: "Total spend + per-category breakdown stored in SQLite.",
note: "Total spend + per-category breakdown stored in SQLite."
};
} catch (err) {
return {
step: "5_analytics_summary",
timestamp,
status: "n/a",
error: String(err),
error: String(err)
};
}
}
Expand All @@ -308,7 +310,7 @@ function assemble(steps: Step[]): Transcript {
"Generated in DEMO_MODE. No real Stellar credentials or live payments " +
"were used. All secret fields are redacted. " +
"Safe to attach to Drips/SCF updates and investor notes.",
steps,
steps
};
}

Expand All @@ -323,13 +325,13 @@ function toText(t: Transcript): string {
` Network : ${t.settlement_network}`,
` ⚠ ${t.warning}`,
bar,
"",
""
];
for (const s of t.steps) {
lines.push(`▶ ${s.step}`);
lines.push(` Timestamp : ${s.timestamp}`);
lines.push(` Status : ${s.status}`);
if (s.note) lines.push(` Note : ${s.note}`);
if (s.note) lines.push(` Note : ${s.note}`);
if (s.error) lines.push(` ERROR : ${s.error}`);
if (s.body) {
lines.push(" Body:");
Expand All @@ -348,10 +350,10 @@ function writeArtifact(t: Transcript): { json: string; txt: string } {

const slug = t.generated_at.replace(/[:.]/g, "-").replace("T", "_");
const jsonPath = path.join(OUT_DIR, `demo-transcript-${slug}.json`);
const txtPath = path.join(OUT_DIR, `demo-transcript-${slug}.txt`);
const txtPath = path.join(OUT_DIR, `demo-transcript-${slug}.txt`);

fs.writeFileSync(jsonPath, JSON.stringify(t, null, 2), "utf8");
fs.writeFileSync(txtPath, toText(t), "utf8");
fs.writeFileSync(txtPath, toText(t), "utf8");
return { json: jsonPath, txt: txtPath };
}

Expand Down Expand Up @@ -397,4 +399,4 @@ async function main(): Promise<void> {
main().catch((err) => {
console.error("Fatal:", err);
process.exit(1);
});
});
6 changes: 3 additions & 3 deletions apps/agent-client/src/validate-real.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,9 @@ describe("safeFacilitatorUrl", () => {

it("strips auth credentials from URL", async () => {
const { safeFacilitatorUrl } = await import("./validate-real.js");
expect(
safeFacilitatorUrl("https://user:password@channels.openzeppelin.com/x402/testnet")
).toBe("channels.openzeppelin.com");
expect(safeFacilitatorUrl("https://user:password@channels.openzeppelin.com/x402/testnet")).toBe(
"channels.openzeppelin.com"
);
});

it("preserves port in host", async () => {
Expand Down
Loading
Loading