Skip to content

Commit dfddafa

Browse files
Merge remote-tracking branch 'origin/deploy' into feat/obs-service-scope-ttft
2 parents 0d5ab18 + 37f4c67 commit dfddafa

6 files changed

Lines changed: 253 additions & 26 deletions

File tree

agentos/src/api.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,23 @@ export interface PIIDetectionConfig {
216216
custom_pii?: CustomPII[];
217217
}
218218

219+
/** One SRS connectivity probe (reachability + authenticated API call). */
220+
export interface SrsProbe {
221+
configured: boolean;
222+
reachable: boolean;
223+
authenticated: boolean;
224+
latencyMs: number | null;
225+
error?: string;
226+
}
227+
228+
/** GET /srs/health — the full guardrail chain, measured server-side:
229+
* `agentos` = agentos-server → SRS; `cas` = agentos → CAS + CAS → SRS.
230+
* The response arriving at all proves the SPA → agentos hop. */
231+
export interface SrsHealth {
232+
agentos: SrsProbe;
233+
cas: { reachable: boolean; srs: SrsProbe | null; error?: string };
234+
}
235+
219236
export interface PolicyDoc {
220237
_id: string;
221238
name: string;
@@ -514,6 +531,9 @@ export const api = {
514531
deleteSchedule: (id: string) => reqJSON<{ ok: boolean }>("DELETE", `/schedules/${encodeURIComponent(id)}`),
515532
runScheduleNow: (id: string) => postJSON<{ ok: boolean }>(`/schedules/${encodeURIComponent(id)}/run-now`, {}),
516533
// Policies — SRS-proxied. Server injects x-api-key.
534+
// SRS connectivity, measured server-side: raw /health reachability + an
535+
// authenticated /v1/rai/policies probe (the exact path this UI uses).
536+
srsHealth: () => getJSON<SrsHealth>("/srs/health"),
517537
policies: () => getJSON<{ policies: PolicyDoc[] }>("/policies").then((d) => d.policies),
518538
policy: (id: string) => getJSON<PolicyDoc>(`/policies/${encodeURIComponent(id)}`),
519539
createPolicy: (body: Partial<PolicyDoc>) => postJSON<PolicyDoc>("/policies", body),

agentos/src/components/PoliciesPage.tsx

Lines changed: 92 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useEffect, useState } from "react";
2-
import { api, type PolicyDoc, type CedarPolicyEntry, type OPAPolicyDoc, type PIIAction } from "../api.ts";
2+
import { api, type PolicyDoc, type CedarPolicyEntry, type OPAPolicyDoc, type PIIAction, type SrsHealth } from "../api.ts";
33
import { useAuth } from "../context/AuthContext.tsx";
44

55
/**
@@ -49,6 +49,7 @@ export function PoliciesPage() {
4949
<div>
5050
<div className="text-base font-semibold">Policies</div>
5151
<div className="text-[11px] text-gray-500">SRS-managed · Cedar + OPA</div>
52+
<SrsStatus />
5253
</div>
5354
{canWrite && (
5455
<button
@@ -79,7 +80,9 @@ export function PoliciesPage() {
7980
>
8081
<div className="flex items-center gap-2">
8182
<span className="text-sm font-medium truncate">{p.name}</span>
82-
<span className="ml-auto text-[10px] text-gray-600 font-mono">{p._id.slice(-6)}</span>
83+
<span className="ml-auto" onClick={(e) => e.stopPropagation()}>
84+
<CopyableId id={p._id} short />
85+
</span>
8386
</div>
8487
<div className="text-[11px] text-gray-500 mt-0.5 truncate">{p.description || <em>no description</em>}</div>
8588
<div className="text-[10px] text-accent-soft mt-1 flex gap-2">
@@ -260,9 +263,7 @@ function PolicyEditor({
260263
<div className="p-6 max-w-3xl">
261264
<div className="flex items-center justify-between mb-5">
262265
<h2 className="text-lg font-semibold">{initial ? "Edit policy" : "Create policy"}</h2>
263-
{initial && (
264-
<div className="text-[11px] text-gray-500 font-mono">{initial._id}</div>
265-
)}
266+
{initial && <CopyableId id={initial._id} />}
266267
</div>
267268
{err && <div className="mb-3 text-sm text-red-400">{err}</div>}
268269

@@ -645,6 +646,92 @@ function RegoPoliciesModal({
645646
);
646647
}
647648

649+
/**
650+
* SRS connectivity pill. Polls `/srs/health` on mount, on every refresh, and
651+
* every 30s — both signals are measured from agentos-server (the browser has
652+
* no route to SRS): raw reachability + the authenticated proxy path the
653+
* Policies UI itself uses. Click to re-check immediately.
654+
*/
655+
function SrsStatus() {
656+
const [health, setHealth] = useState<SrsHealth | null>(null);
657+
const [agentosUp, setAgentosUp] = useState<boolean | null>(null); // the fetch itself = SPA → agentos
658+
const [checking, setChecking] = useState(false);
659+
660+
const check = () => {
661+
setChecking(true);
662+
api.srsHealth()
663+
.then((h) => { setHealth(h); setAgentosUp(true); })
664+
.catch(() => { setHealth(null); setAgentosUp(false); })
665+
.finally(() => setChecking(false));
666+
};
667+
useEffect(() => {
668+
check();
669+
const t = setInterval(check, 30_000);
670+
return () => clearInterval(t);
671+
}, []);
672+
673+
const probeState = (p: { configured: boolean; reachable: boolean; authenticated: boolean; latencyMs: number | null; error?: string } | null | undefined, fallback: string) => {
674+
if (!p) return { dot: "bg-gray-500", text: fallback };
675+
if (!p.configured) return { dot: "bg-gray-500", text: "not configured" };
676+
if (p.authenticated) return { dot: "bg-emerald-500", text: `connected · ${p.latencyMs}ms` };
677+
if (p.reachable) return { dot: "bg-amber-500", text: "reachable, auth failing" };
678+
return { dot: "bg-red-500", text: "unreachable" };
679+
};
680+
681+
const rows: Array<{ label: string; dot: string; text: string; title?: string }> = [
682+
agentosUp === false
683+
? { label: "AgentOS", dot: "bg-red-500", text: "unreachable" }
684+
: { label: "AgentOS", dot: agentosUp ? "bg-emerald-500" : "bg-gray-500", text: agentosUp ? "connected" : "checking…" },
685+
{ label: "AgentOS → SRS", ...probeState(health?.agentos, "checking…"), title: health?.agentos?.error },
686+
health && !health.cas.reachable
687+
? { label: "CAS → SRS", dot: "bg-red-500", text: "CAS unreachable", title: health.cas.error }
688+
: { label: "CAS → SRS", ...probeState(health?.cas.srs, health ? "no /srs/health (old CAS?)" : "checking…"), title: health?.cas.error ?? health?.cas.srs?.error },
689+
];
690+
691+
return (
692+
<button
693+
type="button"
694+
onClick={check}
695+
title="Click to re-check connectivity"
696+
className={`mt-1.5 block text-left space-y-0.5 ${checking ? "opacity-60" : ""}`}
697+
>
698+
{rows.map((r) => (
699+
<span key={r.label} title={r.title} className="flex items-center gap-1.5 text-[10px] text-gray-500 hover:text-gray-300">
700+
<span className={`h-1.5 w-1.5 rounded-full ${r.dot}`} />
701+
<span className="w-24 text-left">{r.label}</span>
702+
<span>{r.text}</span>
703+
</span>
704+
))}
705+
</button>
706+
);
707+
}
708+
709+
/**
710+
* Click-to-copy policy id. Shown wherever a policy surfaces so users can paste
711+
* the id straight into the SDK (`ComputerAgent(policy_id="…")`). `short` shows
712+
* the id's tail in tight rows; the copied value is always the full id.
713+
*/
714+
export function CopyableId({ id, short = false }: { id: string; short?: boolean }) {
715+
const [copied, setCopied] = useState(false);
716+
const copy = async () => {
717+
try {
718+
await navigator.clipboard.writeText(id);
719+
setCopied(true);
720+
setTimeout(() => setCopied(false), 1200);
721+
} catch { /* clipboard unavailable (http) — ignore */ }
722+
};
723+
return (
724+
<button
725+
type="button"
726+
onClick={copy}
727+
title={`Copy policy id: ${id}`}
728+
className="text-[10px] text-gray-500 hover:text-accent font-mono inline-flex items-center gap-1"
729+
>
730+
{copied ? "copied ✓" : short ? `…${id.slice(-6)} ⧉` : `${id} ⧉`}
731+
</button>
732+
);
733+
}
734+
648735
function Section({ title, children, right }: { title: string; children: React.ReactNode; right?: React.ReactNode }) {
649736
return (
650737
<div className="mb-5 rounded-lg border border-ink-600 bg-ink-800 p-4">

agentos/src/components/PolicyTab.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { useEffect, useState } from "react";
22
import { api, type PolicyDoc, type AgentPolicyBinding } from "../api.ts";
3+
import { CopyableId } from "./PoliciesPage.tsx";
34

45
/**
56
* Per-agent policy attachment. Dropdown to bind one of the policies fetched
@@ -72,7 +73,13 @@ export function PolicyTab({
7273
<div className="text-sm text-gray-500">Loading…</div>
7374
) : boundPolicy ? (
7475
<div>
75-
<div className="text-sm font-medium">{boundPolicy.name}</div>
76+
<div className="flex items-center gap-2">
77+
<span className="text-sm font-medium">{boundPolicy.name}</span>
78+
<CopyableId id={boundPolicy._id} />
79+
</div>
80+
<div className="text-[10px] text-gray-600 mt-0.5">
81+
Use in the SDK: <code className="font-mono">ComputerAgent(policy_id="{boundPolicy._id}")</code>
82+
</div>
7683
<div className="text-xs text-gray-500 mt-0.5">{boundPolicy.description || <em>no description</em>}</div>
7784
<div className="text-[11px] text-accent-soft mt-1">{guardrailSummary(boundPolicy)}</div>
7885
<button

examples/computeragent-server.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -950,6 +950,43 @@ export class ComputerAgentServer {
950950
}),
951951
);
952952

953+
// SRS connectivity as seen from THIS server — the exact link the gateway's
954+
// PII redaction and the SrsPolicyDecider depend on. `reachable` = raw
955+
// GET /health; `authenticated` = an x-api-key'd /v1/rai/policies probe.
956+
// Surfaced in the AgentOS UI's SRS status pill (proxied via agentos-server).
957+
this.app.get("/srs/health", async (c) => {
958+
const srsBase = (process.env.SRS_BASE_URL ?? "").replace(/\/+$/, "");
959+
const srsKey = process.env.SRS_API_KEY ?? "";
960+
if (!srsBase) {
961+
return c.json({ configured: false, reachable: false, authenticated: false, latencyMs: null });
962+
}
963+
const t0 = Date.now();
964+
let reachable = false;
965+
let authenticated = false;
966+
let error: string | null = null;
967+
try {
968+
const h = await fetch(`${srsBase}/health`, { signal: AbortSignal.timeout(5000) });
969+
reachable = h.ok;
970+
if (reachable) {
971+
const a = await fetch(`${srsBase}/v1/rai/policies`, {
972+
headers: { "x-api-key": srsKey },
973+
signal: AbortSignal.timeout(5000),
974+
});
975+
authenticated = a.ok;
976+
if (!a.ok) error = `auth check: SRS ${a.status}`;
977+
}
978+
} catch (err) {
979+
error = (err as Error).message;
980+
}
981+
return c.json({
982+
configured: true,
983+
reachable,
984+
authenticated,
985+
latencyMs: Date.now() - t0,
986+
...(error ? { error } : {}),
987+
});
988+
});
989+
953990
this.app.post("/run", async (c) => {
954991
const max = this.opts.maxConcurrentRuns ?? 4;
955992
if (this.runs.size >= max) {

packages/agentos-server/src/routes/policies.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,67 @@ async function srs(
8181
}
8282
}
8383

84+
// ── SRS connectivity health ────────────────────────────────────────────────
85+
// The full guardrail chain, measured server-side (the browser has no route to
86+
// SRS or the CAS):
87+
// agentos — THIS server → SRS: raw GET /health reachability + an x-api-key'd
88+
// /v1/rai/policies probe (the exact proxy path the Policies UI uses).
89+
// cas — THIS server → CAS (is the harness up?) and CAS → SRS (the link
90+
// the gateway PII redaction + SrsPolicyDecider depend on), relayed
91+
// from the CAS's own /srs/health.
92+
// The SPA polls this to render the SRS status pill. Responding at all proves
93+
// the SPA → agentos hop.
94+
interface SrsProbe {
95+
configured: boolean;
96+
reachable: boolean;
97+
authenticated: boolean;
98+
latencyMs: number | null;
99+
error?: string;
100+
}
101+
102+
async function probeSrs(): Promise<SrsProbe> {
103+
if (!SRS_BASE) return { configured: false, reachable: false, authenticated: false, latencyMs: null };
104+
const t0 = Date.now();
105+
let reachable = false;
106+
let authenticated = false;
107+
let error: string | null = null;
108+
try {
109+
const h = await fetch(`${SRS_BASE}/health`, { signal: AbortSignal.timeout(5000) });
110+
reachable = h.ok;
111+
if (reachable) {
112+
const a = await fetch(`${SRS_BASE}/v1/rai/policies`, {
113+
headers: { "x-api-key": SRS_KEY },
114+
signal: AbortSignal.timeout(5000),
115+
});
116+
authenticated = a.ok;
117+
if (!a.ok) error = `auth check: SRS ${a.status}`;
118+
}
119+
} catch (err) {
120+
error = (err as Error).message;
121+
}
122+
return { configured: true, reachable, authenticated, latencyMs: Date.now() - t0, ...(error ? { error } : {}) };
123+
}
124+
125+
async function probeCasSrs(): Promise<{ reachable: boolean; srs: SrsProbe | null; error?: string }> {
126+
const { caBase } = await import("../upstream.js");
127+
const { caAuthHeader } = await import("../auth.js");
128+
try {
129+
const r = await fetch(`${caBase()}/srs/health`, {
130+
headers: caAuthHeader(),
131+
signal: AbortSignal.timeout(5000),
132+
});
133+
if (!r.ok) return { reachable: true, srs: null, error: `CAS /srs/health → ${r.status}` };
134+
return { reachable: true, srs: (await r.json()) as SrsProbe };
135+
} catch (err) {
136+
return { reachable: false, srs: null, error: (err as Error).message };
137+
}
138+
}
139+
140+
policiesRouter.get("/srs/health", authorize("policies:read"), async (_req, res) => {
141+
const [agentos, cas] = await Promise.all([probeSrs(), probeCasSrs()]);
142+
res.json({ agentos, cas });
143+
});
144+
84145
// ── RAI policies → SRS /v1/rai/policies ───────────────────────────────────
85146
policiesRouter.get("/policies", authorize("policies:read"), (_req, res) =>
86147
srs(res, "GET", "/v1/rai/policies", { fallback: { policies: [] } }),

packages/llm-proxy-openai/src/proxy.ts

Lines changed: 35 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -300,8 +300,14 @@ async function srsRedactText(
300300

301301
/**
302302
* Redact every prompt text span in an Anthropic request in place (system +
303-
* message text blocks). Accumulates one placeholder→original map across all
304-
* spans. Short-circuits with `blocked` if any span trips a `block` action.
303+
* message text blocks), accumulating one placeholder→original map.
304+
*
305+
* Only the LATEST user message may BLOCK. The system prompt and prior history
306+
* are redacted (masked) but never block — the client resends the full
307+
* conversation each turn, so a one-time `block`-action value (e.g. a phone
308+
* number the user typed earlier) would otherwise re-block every subsequent
309+
* turn forever. SRS still returns the masked text alongside a block verdict,
310+
* so history values are scrubbed regardless.
305311
*/
306312
async function redactAnthropicRequest(
307313
body: AnthropicRequest,
@@ -311,40 +317,49 @@ async function redactAnthropicRequest(
311317
): Promise<{ blocked: boolean; blockReason?: string; map: Record<string, string> }> {
312318
const map: Record<string, string> = {};
313319

314-
const run = async (text: string): Promise<string | null> => {
320+
// Redact a span + merge its map; always returns the (masked) text even on block.
321+
const redact = async (text: string) => {
315322
const r = await srsRedactText(text, policyId, cfg, log);
316-
if (r.blocked) return null; // signal block to caller
317323
Object.assign(map, r.map);
318-
return r.text;
324+
return r;
319325
};
320326

321-
// system prompt
327+
// Index of the most recent user message — the only span allowed to block.
328+
let lastUserIdx = -1;
329+
for (let i = body.messages.length - 1; i >= 0; i--) {
330+
if (body.messages[i]?.role === "user") { lastUserIdx = i; break; }
331+
}
332+
333+
// system prompt — redact only, never block
322334
if (typeof body.system === "string") {
323-
const out = await run(body.system);
324-
if (out === null) return { blocked: true, blockReason: "PII policy blocked system prompt", map };
325-
body.system = out;
335+
body.system = (await redact(body.system)).text;
326336
} else if (Array.isArray(body.system)) {
327337
for (const blk of body.system) {
328338
if (blk && typeof blk === "object" && typeof blk.text === "string") {
329-
const out = await run(blk.text);
330-
if (out === null) return { blocked: true, map };
331-
blk.text = out;
339+
blk.text = (await redact(blk.text)).text;
332340
}
333341
}
334342
}
335343

336-
// messages
337-
for (const msg of body.messages) {
344+
// messages — redact all; honour `block` only for the latest user message
345+
for (let i = 0; i < body.messages.length; i++) {
346+
const msg = body.messages[i];
347+
if (!msg) continue;
348+
const canBlock = i === lastUserIdx;
338349
if (typeof msg.content === "string") {
339-
const out = await run(msg.content);
340-
if (out === null) return { blocked: true, map };
341-
msg.content = out;
350+
const r = await redact(msg.content);
351+
msg.content = r.text;
352+
if (canBlock && r.blocked) {
353+
return { blocked: true, ...(r.blockReason ? { blockReason: r.blockReason } : {}), map };
354+
}
342355
} else if (Array.isArray(msg.content)) {
343356
for (const blk of msg.content) {
344357
if (blk.type === "text" && typeof blk.text === "string") {
345-
const out = await run(blk.text);
346-
if (out === null) return { blocked: true, map };
347-
blk.text = out;
358+
const r = await redact(blk.text);
359+
blk.text = r.text;
360+
if (canBlock && r.blocked) {
361+
return { blocked: true, ...(r.blockReason ? { blockReason: r.blockReason } : {}), map };
362+
}
348363
}
349364
}
350365
}

0 commit comments

Comments
 (0)