diff --git a/eslint.config.mjs b/eslint.config.mjs index 8283c57fd..4567fd9c3 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -58,6 +58,14 @@ export default tseslint.config( message: "Read configuration through loadConfig() (src/config.ts) and pass it down — process.env is parsed exactly once at the boundary.", }, + { + // Never dump a raw error object to the console: an error can carry HTTP + // response bodies, connection strings, or credential material. Wrap it in + // errMessage(...) (src/util/errors.ts) so only the message is logged. + selector: "CallExpression[callee.object.name='console'] > Identifier.arguments[name=/^(e|err|error)$/]", + message: + "Pass errMessage(e), not the raw error object, to console.* — raw errors can leak response bodies or secrets into logs.", + }, { selector: "VariableDeclarator[init.name='process'] ObjectPattern Property[key.name='env']", message: @@ -85,4 +93,19 @@ export default tseslint.config( ], }, }, + { + // Same raw-error rule for plugin server code and the src files the env-boundary + // block above deliberately skips (local scripts/ and test/ CLIs keep full stacks). + files: ["plugins/**/*.ts", "src/config.ts", "src/index.ts", "src/runs/worker-main.ts", "src/egress-authz-main.ts"], + rules: { + "no-restricted-syntax": [ + "error", + { + selector: "CallExpression[callee.object.name='console'] > Identifier.arguments[name=/^(e|err|error)$/]", + message: + "Pass errMessage(e), not the raw error object, to console.* — raw errors can leak response bodies or secrets into logs.", + }, + ], + }, + }, ); diff --git a/plugins/admin/public/index.html b/plugins/admin/public/index.html index e7c0367b0..d06d4996a 100644 --- a/plugins/admin/public/index.html +++ b/plugins/admin/public/index.html @@ -2905,6 +2905,30 @@ text-overflow: ellipsis; white-space: nowrap; } + .environment-notice { + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + margin: 0 0 18px; + padding: 14px 16px; + border: 1px solid color-mix(in srgb, var(--warn) 42%, var(--border)); + border-radius: 10px; + background: color-mix(in srgb, var(--warn) 8%, var(--surface)); + } + .environment-notice strong, + .environment-notice p { + display: block; + margin: 0; + } + .environment-notice p { + margin-top: 3px; + color: var(--muted); + font-size: 12px; + } + .environment-notice button { + flex: none; + } .governance-overview { margin: 0 0 22px; padding: 18px 20px; @@ -3987,6 +4011,13 @@

Governance

ScopeOrganization +

Effective state

@@ -5717,6 +5748,7 @@

Confirm governance change

}); let scopeDir = null; + let environmentDir = []; let scopeDirNote = "Loading scopes…"; async function loadScopeDirectory() { const r = await api("GET", "/api/scopes"); @@ -5728,6 +5760,7 @@

Confirm governance change

} viewLoadedAt.history = Date.now(); scopeDir = r.data.scopes || []; + environmentDir = r.data.environments || []; if (SCOPED.has(view) && !urlToState().session) { const memoryEditor = view === "memory" && !(orgWideView() && urlToState().mem !== "edit"); @@ -6244,6 +6277,19 @@

Confirm governance change

); } window.addEventListener("scroll", syncGovernanceSectionNav, { passive: true }); + function renderEnvironmentNotice(data) { + const notice = $("environment-notice"); + const attachment = data?.environmentAttachment; + notice.classList.toggle("hidden", !attachment); + if (!attachment) return; + const name = attachment.environmentName || shortName(attachment.environmentId); + $("environment-notice-title").textContent = "Uses named environment " + name; + $("environment-notice-detail").textContent = + "Computer files and working memory resolve to this environment. Governance and conversation history remain scoped here."; + $("environment-notice-open").textContent = "Open " + name; + $("environment-notice-open").onclick = () => + go({ view: "governance", scope: attachment.environmentId, session: null, page: 1 }); + } let governanceReq = 0; async function loadScope() { const requestedScope = scope; @@ -6259,6 +6305,7 @@

Confirm governance change

); return; } + renderEnvironmentNotice(r.data); renderGovernanceOverview(r.data); syncGovernanceSectionNav(); loadedCommandPolicyPresent = r.data.commandPolicy != null; @@ -11451,6 +11498,21 @@

Confirm governance change

actions: [sortControl], }); const activityTime = (s) => (scopeSort === "human" ? s.lastConversationActivity || 0 : s.lastActivity || 0); + if (environmentDir.length) { + const environments = denseList( + environmentDir, + (environment) => ({ + name: environment.name || shortName(environment.id), + preview: plural(environment.attachedScopes?.length || 0, "attached scope"), + href: stateToUrl({ view: "history", scope: environment.id, historyKind }), + }), + (environment) => selectScope(environment.id), + "No named environments.", + ); + root.appendChild( + dataCard("Named environments", "Named computers and working memory that scopes can share.", environments), + ); + } const t = denseList( activeRows, (s) => { diff --git a/plugins/admin/src/index.ts b/plugins/admin/src/index.ts index 25106783a..31b62cb25 100644 --- a/plugins/admin/src/index.ts +++ b/plugins/admin/src/index.ts @@ -141,7 +141,7 @@ async function forward( res.writeHead(r.status, { "content-type": "application/json" }); pipeBody(res, r.body); } catch (err) { - console.error("[admin] core request failed:", err); + console.error("[admin] core request failed:", String(err)); json(res, 502, { error: "core_unreachable", message: "core unavailable" }); } } @@ -176,7 +176,7 @@ async function forwardDownload(res: ServerResponse, principal: string, corePath: res.writeHead(r.status, headers); pipeBody(res, r.body); } catch (err) { - console.error("[admin] core download failed:", err); + console.error("[admin] core download failed:", String(err)); json(res, 502, { error: "core_unreachable", message: "core unavailable" }); } } @@ -236,7 +236,7 @@ async function uploadFileFromRequest( }); return forward(req, res, principal, "POST", corePath, body); } catch (err) { - console.error("[admin] upload failed:", err); + console.error("[admin] upload failed:", String(err)); return json(res, 502, { error: "core_unreachable", message: "core unavailable" }); } } @@ -301,7 +301,7 @@ const server = createServer((req, res) => { void portalTokenStore .run(token, () => handle(req, res)) .catch((err: unknown) => { - console.error("[admin] unhandled request error:", err); + console.error("[admin] unhandled request error:", String(err)); json(res, 500, { error: "internal_error", message: "internal server error" }); }); }); @@ -402,7 +402,7 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise res.writeHead(r.status, { "content-type": "application/json" }); res.end(text); } catch (err) { - console.error("[admin] core request failed:", err); + console.error("[admin] core request failed:", String(err)); json(res, 502, { error: "core_unreachable", message: "core unavailable" }); } return; diff --git a/plugins/admin/test/environments.test.ts b/plugins/admin/test/environments.test.ts new file mode 100644 index 000000000..3ec8c9fb8 --- /dev/null +++ b/plugins/admin/test/environments.test.ts @@ -0,0 +1,13 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +const html = readFileSync(join(import.meta.dirname, "../public/index.html"), "utf8"); + +test("the admin UI lists named environments and links attachment warnings", () => { + assert.match(html, /Named environments/); + assert.match(html, /id="environment-notice"/); + assert.match(html, /Uses named environment/); + assert.match(html, /scope: attachment\.environmentId/); +}); diff --git a/plugins/auth/src/index.ts b/plugins/auth/src/index.ts index ee2533206..b63ba85a3 100644 --- a/plugins/auth/src/index.ts +++ b/plugins/auth/src/index.ts @@ -32,7 +32,7 @@ export async function startServer(): Promise { }); const server = createServer((req, res) => { void handle(req, res).catch((err: unknown) => { - console.error(`[auth] 500 ${req.method ?? "?"} ${(req.url ?? "?").split("?")[0]}:`, err); + console.error("[auth] 500 %s %s: %s", req.method ?? "?", (req.url ?? "?").split("?")[0], String(err)); if (!res.headersSent) json(res, 500, { error: "internal_error" }); else res.end(); }); diff --git a/plugins/chassis/src/branding.ts b/plugins/chassis/src/branding.ts index 82ccb7e9e..3ba6758de 100644 --- a/plugins/chassis/src/branding.ts +++ b/plugins/chassis/src/branding.ts @@ -29,7 +29,7 @@ export function createBrandingCache(fetchBranding: () => Promise): warmed = true; nextAt = Date.now() + REFRESH_MS; } catch (err) { - if (process.env.BRANDING_DEBUG) console.error("[branding] fetch failed:", err); + if (process.env.BRANDING_DEBUG) console.error("[branding] fetch failed:", String(err)); nextAt = Date.now() + RETRY_MS; } finally { inflight = null; diff --git a/plugins/portal/src/index.ts b/plugins/portal/src/index.ts index 2ecde29cd..395064e37 100644 --- a/plugins/portal/src/index.ts +++ b/plugins/portal/src/index.ts @@ -812,7 +812,7 @@ function renewSessionCookie(req: IncomingMessage, res: ServerResponse): void { const server = createServer((req, res) => { void handle(req, res).catch((err: unknown) => { - console.error(`[portal] 500 ${req.method ?? "?"} ${(req.url ?? "?").split("?")[0]}:`, err); + console.error("[portal] 500 %s %s: %s", req.method ?? "?", (req.url ?? "?").split("?")[0], String(err)); if (!res.headersSent) json(res, 500, { error: "internal_error" }); else res.end(); }); diff --git a/plugins/web-ui/server/index.ts b/plugins/web-ui/server/index.ts index 08d0b2a46..f05ebb1e4 100644 --- a/plugins/web-ui/server/index.ts +++ b/plugins/web-ui/server/index.ts @@ -1918,7 +1918,7 @@ export const handler = async (req: IncomingMessage, res: ServerResponse) => { const server = createServer((req, res) => { void handler(req, res).catch((err: unknown) => { - console.error(`[web-ui] 502 ${req.method ?? "?"} ${req.url ?? "?"}:`, err); + console.error("[web-ui] 502 %s %s: %s", req.method ?? "?", req.url ?? "?", String(err)); if (!res.headersSent) json(res, 502, { error: "bad_gateway", message: "upstream error" }); else res.end(); }); @@ -1942,7 +1942,7 @@ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { }); }) .catch((err: unknown) => { - console.error("[web-ui] failed to start:", err); + console.error("[web-ui] failed to start:", String(err)); process.exit(1); }); } diff --git a/src/admin/postgres-audit-log.ts b/src/admin/postgres-audit-log.ts index 8a6023056..fda9e3ddd 100644 --- a/src/admin/postgres-audit-log.ts +++ b/src/admin/postgres-audit-log.ts @@ -1,6 +1,7 @@ import { createPgPool } from "../persistence/pg-pool.ts"; import type { ScopeId } from "../types.ts"; import type { AuditEvent, AuditLog } from "../audit/audit-log.ts"; +import { errMessage } from "../util/errors.ts"; function rowToEvent(r: Record): AuditEvent { return { @@ -61,7 +62,7 @@ export function createPostgresAuditLog(connectionString: string): AuditLog { e.detail ?? null, ]) .then(() => undefined) - .catch((err) => console.error("[audit] failed to persist event to durable store:", err)); + .catch((err) => console.error("[audit] failed to persist event to durable store:", errMessage(err))); pendingWrites.add(write); void write.finally(() => pendingWrites.delete(write)); }, diff --git a/src/admin/postgres-metrics-sink.ts b/src/admin/postgres-metrics-sink.ts index 0fbdf1f03..29187f828 100644 --- a/src/admin/postgres-metrics-sink.ts +++ b/src/admin/postgres-metrics-sink.ts @@ -1,5 +1,6 @@ import { createPostgresEventSink, type EventColumn } from "./scoped-event-sink.ts"; import type { MetricsSink, TurnMetricSample } from "./metrics-sink.ts"; +import { errMessage } from "../util/errors.ts"; const COLUMNS: readonly EventColumn[] = [ ["ts", "ts", "BIGINT", "number", true], @@ -76,7 +77,7 @@ export function createPostgresMetricsSink(connectionString: string): MetricsSink params.push(runId); await sink .q(`UPDATE turn_metrics SET ${sets.join(", ")} WHERE run_id = $${params.length}`, params) - .catch((err) => console.error("[metrics] failed to patch turn metric:", err)); + .catch((err) => console.error("[metrics] failed to patch turn metric:", errMessage(err))); }, list: (opts = {}) => sink.list(opts), }; diff --git a/src/admin/scoped-event-sink.ts b/src/admin/scoped-event-sink.ts index 25f1dc3ac..48eff01f0 100644 --- a/src/admin/scoped-event-sink.ts +++ b/src/admin/scoped-event-sink.ts @@ -1,5 +1,6 @@ import type { ScopeId } from "../types.ts"; import { createPgPool, type PgPool } from "../persistence/pg-pool.ts"; +import { errMessage } from "../util/errors.ts"; export interface ScopedEvent { scopeLabel: ScopeId; @@ -162,7 +163,7 @@ export function createPostgresEventSink(cfg: PostgresEventSinkConfig): Pos const s = input as Record; const values = cfg.columns.map(([, js]) => (js === "ts" ? Date.now() : (s[js] ?? null))); const write = q(insertSql, values) - .catch((err) => console.error(cfg.persistErrorMessage, err)) + .catch((err) => console.error(cfg.persistErrorMessage, errMessage(err))) .finally(() => pendingWrites.delete(write)); pendingWrites.add(write); }, diff --git a/src/api/routes/admin/scope-config.ts b/src/api/routes/admin/scope-config.ts index 22acc4f7e..17d206168 100644 --- a/src/api/routes/admin/scope-config.ts +++ b/src/api/routes/admin/scope-config.ts @@ -132,10 +132,25 @@ export async function listAdminScopes(ctx: ApiCtx): Promise { const crons = await app.listCrons(); const deployments = await app.listDeployments(); const skills = await app.listSkills(); + const environmentRows = await app.listEnvironments(); + const environments = environmentRows.map(({ environment, attachments }) => ({ + id: environment.id, + name: environment.name, + ownerActorId: environment.ownerActorId, + attachedScopes: attachments.map((attachment) => attachment.scopeId).sort(), + })); + const environmentById = new Map(environments.map((environment) => [environment.id, environment])); + const attachmentByScope = new Map( + environments.flatMap((environment) => + environment.attachedScopes.map((attachedScope) => [attachedScope, environment] as const), + ), + ); const owners = [ ...crons.map((c) => c.ownerScopeId), ...deployments.map((d) => d.ownerScopeId), ...skills.map((s) => s.scopeId), + ...environments.map((environment) => environment.id), + ...environments.flatMap((environment) => environment.attachedScopes), ]; const labels = await discoverScopes(app, deps, owners); const countBy = (ids: string[]): Map => { @@ -171,18 +186,31 @@ export async function listAdminScopes(ctx: ApiCtx): Promise { const cronN = countBy(crons.map((c) => c.ownerScopeId)); const deployN = countBy(deployments.map((d) => d.ownerScopeId)); const skillN = countBy(skills.map((s) => s.scopeId)); - const scopes = [...labels].map(([id, label]) => ({ - scopeId: id, - ...(label ? { label } : {}), - sessions: sessionN.get(id) ?? 0, - backgroundSessions: backgroundN.get(id) ?? 0, - lastActivity: lastActivityBy.get(id) ?? 0, - lastConversationActivity: lastConversationBy.get(id) ?? 0, - lastMessage: lastMessageBy.get(id) ?? "", - crons: cronN.get(id) ?? 0, - deployments: deployN.get(id) ?? 0, - skills: skillN.get(id) ?? 0, - })); + const scopes = [...labels].map(([id, label]) => { + const environment = environmentById.get(id); + const attachment = attachmentByScope.get(id); + return { + scopeId: id, + ...(label ? { label } : {}), + ...(environment?.name ? { environmentName: environment.name } : {}), + ...(attachment + ? { + environmentAttachment: { + environmentId: attachment.id, + environmentName: attachment.name, + }, + } + : {}), + sessions: sessionN.get(id) ?? 0, + backgroundSessions: backgroundN.get(id) ?? 0, + lastActivity: lastActivityBy.get(id) ?? 0, + lastConversationActivity: lastConversationBy.get(id) ?? 0, + lastMessage: lastMessageBy.get(id) ?? "", + crons: cronN.get(id) ?? 0, + deployments: deployN.get(id) ?? 0, + skills: skillN.get(id) ?? 0, + }; + }); scopes.sort( (a, b) => b.lastActivity - a.lastActivity || @@ -190,7 +218,35 @@ export async function listAdminScopes(ctx: ApiCtx): Promise { b.backgroundSessions - a.backgroundSessions || a.scopeId.localeCompare(b.scopeId), ); - return sendJson(res, 200, { scopeId: scope, scopes }); + return sendJson(res, 200, { scopeId: scope, scopes, environments }); +} + +interface ScopeEnvironmentMetadata { + environment?: { id: string; name: string; ownerActorId: string | null }; + environmentAttachment?: { environmentId: string; environmentName: string | null }; +} + +async function scopeEnvironmentMetadata(deps: ApiCtx["deps"], targetScope: string): Promise { + const store = deps.environments; + if (!store) return {}; + + const [environment, attachment] = await Promise.all([store.get(targetScope), store.getAttachment(targetScope)]); + const metadata: ScopeEnvironmentMetadata = {}; + if (environment?.name) { + metadata.environment = { + id: environment.id, + name: environment.name, + ownerActorId: environment.ownerActorId, + }; + } + if (attachment) { + const attachedEnvironment = await store.get(attachment.environmentId); + metadata.environmentAttachment = { + environmentId: attachment.environmentId, + environmentName: attachedEnvironment?.name ?? null, + }; + } + return metadata; } export async function getScopeConfig(ctx: ApiCtx): Promise { @@ -202,6 +258,7 @@ export async function getScopeConfig(ctx: ApiCtx): Promise { if (!actor) return; await deps.config.refreshScope(targetScope); audit(deps, { principalId: actor.id, action: "config.read", resource: "config", scopeLabel: targetScope }); + const environmentMetadata = await scopeEnvironmentMetadata(deps, targetScope); const serviceCredentials = await Promise.all( (deps.serviceCreds ? await deps.serviceCreds.listServiceCredentials(targetScope) : []).map(async (c) => { const usage = (await deps.credentialUsage?.list({ slug: c.slug, limit: 5000 })) ?? []; @@ -261,6 +318,7 @@ export async function getScopeConfig(ctx: ApiCtx): Promise { }; return sendJson(res, 200, { scopeId: targetScope, + ...environmentMetadata, ...values, soulVersion: deps.config.soulVersion(targetScope), soulHistory: deps.config.soulHistory(targetScope), diff --git a/src/api/server.ts b/src/api/server.ts index 8b82a11fb..7cd533842 100644 --- a/src/api/server.ts +++ b/src/api/server.ts @@ -299,7 +299,7 @@ function respondError(req: IncomingMessage, res: ServerResponse, err: unknown): else res.destroy(); return; } - console.error(`[server] 500 ${req.method ?? "?"} ${req.url ?? "?"}:`, err); + console.error("[server] 500 %s %s: %s", req.method ?? "?", req.url ?? "?", errMessage(err)); if (!res.headersSent) sendJson(res, 500, { error: "internal_error", message: "internal server error" }); else res.destroy(); } @@ -368,7 +368,7 @@ function buildFastify(wiring: Wiring, server: Server): { fastify: FastifyInstanc fastify.decorateRequest("gate", undefined); fastify.setErrorHandler((err, request, reply) => { - console.error(`[server] 500 ${request.raw.method ?? "?"} ${request.raw.url ?? "?"}:`, err); + console.error(`[server] 500 ${request.raw.method ?? "?"} ${request.raw.url ?? "?"}:`, errMessage(err)); return reply.code(500).send({ error: "internal_error", message: "internal server error" }); }); @@ -451,7 +451,7 @@ function buildServer(app: App, deps: ServerOptions, allowUnsignedSourceAuth: boo }); const { fastify, routing } = buildFastify(wiring, server); const ready = Promise.resolve(fastify.ready()); - ready.catch((err: unknown) => console.error("[server] fastify initialization failed:", err)); + ready.catch((err: unknown) => console.error("[server] fastify initialization failed:", errMessage(err))); server.requestTimeout = 30_000; server.headersTimeout = 10_000; server.keepAliveTimeout = 5_000; diff --git a/src/core/orchestrator.ts b/src/core/orchestrator.ts index cadd8abae..227a10d9d 100644 --- a/src/core/orchestrator.ts +++ b/src/core/orchestrator.ts @@ -511,7 +511,7 @@ export function createOrchestrator(deps: OrchestratorDeps): Orchestrator { try { await deps.sessions.recordLlmRequest(screenSession.id, { ...rec, scopeLabel: scopeId }); } catch (err) { - console.error("[orchestrator] failed to persist security screen request snapshot:", err); + console.error("[orchestrator] failed to persist security screen request snapshot:", errMessage(err)); } }; let screenedOverheard: OverheardEntryPayload[] = []; @@ -2282,7 +2282,7 @@ export function createOrchestrator(deps: OrchestratorDeps): Orchestrator { try { await deps.sessions.recordLlmRequest(session.id, { ...rec, scopeLabel: scopeId }); } catch (err) { - console.error("[orchestrator] failed to persist LLM request snapshot:", err); + console.error("[orchestrator] failed to persist LLM request snapshot:", errMessage(err)); } }, }); diff --git a/src/cron/scheduler.ts b/src/cron/scheduler.ts index 066d79114..2f9237834 100644 --- a/src/cron/scheduler.ts +++ b/src/cron/scheduler.ts @@ -188,12 +188,12 @@ export function createScheduler(deps: SchedulerDeps): Scheduler { const t = nowArg ?? now(); await leaderLease.hold(TICK_LEASE_KEY, async () => { await fireDue(t); - await deps.sweepAsks?.(t).catch((e: unknown) => console.error("[scheduler] ask sweep failed:", e)); + await deps.sweepAsks?.(t).catch((e: unknown) => console.error("[scheduler] ask sweep failed:", errMessage(e))); }); }; const sweeper = createSweeper( - () => tick().catch((e: unknown) => console.error("[scheduler] tick failed:", e)), + () => tick().catch((e: unknown) => console.error("[scheduler] tick failed:", errMessage(e))), 1000, { label: "scheduler" }, ); @@ -227,7 +227,7 @@ export function createScheduler(deps: SchedulerDeps): Scheduler { return; } } catch (e) { - console.error("[scheduler] fire failed:", e); + console.error("[scheduler] fire failed:", errMessage(e)); await deps.crons.unclaimSlot(job.cronId, slot, t, cron.lastFiredAt); return; } @@ -242,9 +242,9 @@ export function createScheduler(deps: SchedulerDeps): Scheduler { if (slot !== undefined) await deps.jobQueue!.enqueueFire({ cronId: cron.id, scheduledAt: slot }); } } catch (e) { - console.error("[scheduler] tick failed:", e); + console.error("[scheduler] tick failed:", errMessage(e)); } - await deps.sweepAsks?.(now()).catch((e: unknown) => console.error("[scheduler] ask sweep failed:", e)); + await deps.sweepAsks?.(now()).catch((e: unknown) => console.error("[scheduler] ask sweep failed:", errMessage(e))); } const leaseGuard = createSweeper( diff --git a/src/delivery/run-result-delivery.ts b/src/delivery/run-result-delivery.ts index 4f8c3959e..588655a10 100644 --- a/src/delivery/run-result-delivery.ts +++ b/src/delivery/run-result-delivery.ts @@ -4,6 +4,7 @@ import type { DeliveryStore } from "./delivery-store.ts"; import type { Task, TaskStore } from "../tasks/task-store.ts"; import { SECURITY_QUARANTINE_REFUSAL_TEXT } from "../../plugins/chassis/src/security-quarantine.ts"; import { resolveTurnOrigin } from "../core/turn-origin.ts"; +import { errMessage } from "../util/errors.ts"; export interface RunResultDelivery { destination: Destination; @@ -56,6 +57,8 @@ export function wireRunResultDeliveries(runs: RunStore, deliveries: DeliveryStor const delivery = runResultDelivery(run, taskList); if (!delivery) return; await deliveries.enqueue(delivery); - })().catch((err) => console.error(`[delivery] failed to enqueue recovery delivery for run ${run.id}:`, err)); + })().catch((err) => + console.error(`[delivery] failed to enqueue recovery delivery for run ${run.id}:`, errMessage(err)), + ); }); } diff --git a/src/harness/pi-harness.ts b/src/harness/pi-harness.ts index 0c8a96b34..4e79d379c 100644 --- a/src/harness/pi-harness.ts +++ b/src/harness/pi-harness.ts @@ -76,6 +76,7 @@ import { ELIDED_IMAGE_TEXT, planTapeSeed } from "./tape-fold.ts"; import { compactTranscript, deterministicCompactSummary, estimateHistoryTokens } from "./context-compaction.ts"; import { countTokens } from "../util/tokens.ts"; import { parseSecurityScreenVerdict, SECURITY_SCREEN_SYSTEM_PROMPT } from "../security/security-posture.ts"; +import { errMessage } from "../util/errors.ts"; export interface PiHarnessOptions { modelId?: string | ((scope?: ScopeId) => string | undefined); @@ -1271,7 +1272,7 @@ export function createPiHarness(opts?: PiHarnessOptions): Harness { try { reconstructed = reconstructMessagesFromHistory(history); } catch (err) { - console.error("[pi-harness] history reconstruction failed; will fall back:", err); + console.error("[pi-harness] history reconstruction failed; will fall back:", errMessage(err)); reconstructed = null; } let foldSeed: PiReplayMessage[] | null = null; @@ -1290,7 +1291,7 @@ export function createPiHarness(opts?: PiHarnessOptions): Harness { ); } } catch (err) { - console.error(`${tag} fold threw:`, err); + console.error(`${tag} fold threw:`, errMessage(err)); } } const seedSource = foldSeed ?? reconstructed; @@ -1337,7 +1338,7 @@ export function createPiHarness(opts?: PiHarnessOptions): Harness { try { seedRawMessagesIntoSession(session, seedSource!); } catch (err) { - console.error("[pi-harness] failed to seed reconstructed history (continuing without it):", err); + console.error("[pi-harness] failed to seed reconstructed history (continuing without it):", errMessage(err)); } } else if (seedPlan === "priorTurns") { try { @@ -1356,7 +1357,7 @@ export function createPiHarness(opts?: PiHarnessOptions): Harness { } } } catch (err) { - console.error("[pi-harness] failed to seed prior turns (continuing without them):", err); + console.error("[pi-harness] failed to seed prior turns (continuing without them):", errMessage(err)); } } diff --git a/src/idempotency/idempotency-store.ts b/src/idempotency/idempotency-store.ts index 012931ee7..f700b64bc 100644 --- a/src/idempotency/idempotency-store.ts +++ b/src/idempotency/idempotency-store.ts @@ -1,4 +1,5 @@ import type { DurableMap } from "../persistence/durable-map.ts"; +import { errMessage } from "../util/errors.ts"; export interface IdempotencyRecord { key: string; @@ -41,7 +42,7 @@ export function createIdempotencyStore( if (r.at < cutoff) await backing.delete(r.key); } })().catch((e: unknown) => { - console.error("[idempotency] retention prune failed:", e); + console.error("[idempotency] retention prune failed:", errMessage(e)); }); } diff --git a/src/monitors/monitor-poller.ts b/src/monitors/monitor-poller.ts index ca8d65307..966a0cc69 100644 --- a/src/monitors/monitor-poller.ts +++ b/src/monitors/monitor-poller.ts @@ -264,13 +264,13 @@ export function createMonitorPoller(deps: MonitorPollerDeps): MonitorPoller { if (await poll(sandbox, handle, m, t)) fires++; } catch (e) { await deps.monitors.recordError(m.id, errMessage(e)); - console.error(`[monitor] poll failed for ${m.id}:`, e); + console.error(`[monitor] poll failed for ${m.id}:`, errMessage(e)); } } } finally { for (const handle of handles.values()) { await sandbox.teardown(handle, { keepWarm: true }).catch((e: unknown) => { - console.error("[monitor] teardown failed:", e); + console.error("[monitor] teardown failed:", errMessage(e)); }); } } @@ -282,7 +282,7 @@ export function createMonitorPoller(deps: MonitorPollerDeps): MonitorPoller { }; const sweeper = createSweeper( - () => tick().catch((e: unknown) => console.error("[monitor] tick failed:", e)), + () => tick().catch((e: unknown) => console.error("[monitor] tick failed:", errMessage(e))), 10_000, { label: "monitor" }, ); diff --git a/src/persistence/pg-pool.ts b/src/persistence/pg-pool.ts index 976bd8e14..d2ed10295 100644 --- a/src/persistence/pg-pool.ts +++ b/src/persistence/pg-pool.ts @@ -1,5 +1,6 @@ import type { Pool, PoolClient } from "pg"; import { swallowAs } from "../util/errors.ts"; +import { errMessage } from "../util/errors.ts"; export type { Pool, PoolClient }; @@ -63,7 +64,7 @@ export function createPgPool(connectionString: string, statements: string[]): Pg poolP = (async () => { const pg = (await import("pg")).default; const p = new pg.Pool({ connectionString }); - p.on("error", (err) => console.error("[pg] idle client error:", err)); + p.on("error", (err) => console.error("[pg] idle client error:", errMessage(err))); try { await applyDdl(p, schema); } catch (e) { diff --git a/src/processes/process-reaper.ts b/src/processes/process-reaper.ts index 3f8ad1b21..51fb3341a 100644 --- a/src/processes/process-reaper.ts +++ b/src/processes/process-reaper.ts @@ -4,6 +4,7 @@ import { createNoopLeaderLease, type LeaderLease } from "../persistence/leader-l import { awaitProcessExit } from "../sandbox/await-process-exit.ts"; import { processIsGone } from "../sandbox/process-poll.ts"; import type { ProcessSandbox } from "../sandbox/sandbox.ts"; +import { errMessage } from "../util/errors.ts"; const PROCESS_REAPER_LEASE_KEY = "processes:reaper"; @@ -66,7 +67,9 @@ export function createProcessReaper(registry: ProcessRegistry, opts: ProcessReap if (!flipped) continue; reaped++; if (opts.onReaped) - await opts.onReaped(rec).catch((err) => console.error("[process-reaper] onReaped hook failed:", err)); + await opts + .onReaped(rec) + .catch((err) => console.error("[process-reaper] onReaped hook failed:", errMessage(err))); } return { reaped }; } diff --git a/src/ratelimit/postgres-budget.ts b/src/ratelimit/postgres-budget.ts index dde1f32ce..d09c8409e 100644 --- a/src/ratelimit/postgres-budget.ts +++ b/src/ratelimit/postgres-budget.ts @@ -1,6 +1,7 @@ import { createPgPool } from "../persistence/pg-pool.ts"; import type { BudgetTracker } from "./budget.ts"; import { DEFAULT_BUDGET_WINDOW_MS } from "./budget.ts"; +import { errMessage } from "../util/errors.ts"; export function createPostgresBudgetTracker( connectionString: string, @@ -45,7 +46,7 @@ export function createPostgresBudgetTracker( ); } } catch (err) { - console.error("[budget] failed to persist spend:", err); + console.error("[budget] failed to persist spend:", errMessage(err)); } }, }; diff --git a/src/resolution/config-store.ts b/src/resolution/config-store.ts index 754afd0b7..66e029c72 100644 --- a/src/resolution/config-store.ts +++ b/src/resolution/config-store.ts @@ -19,6 +19,7 @@ import { type PublicConnectorClient, type DecryptedConnectorClient, } from "../connectors/connector-client-store.ts"; +import { errMessage } from "../util/errors.ts"; export interface PersistedSoul { scopeId: ScopeId; @@ -267,7 +268,8 @@ export function createMemoryConfigStore( const browseModelStore = opts.browseModels ?? createMemoryMap(); const turnWallClockStore = opts.turnWallClocks ?? createMemoryMap(); const deploymentIdentity = opts.deploymentIdentity ?? createMemoryMap(); - const persistWarn = (what: string) => (e: unknown) => console.error(`[config] failed to persist ${what}:`, e); + const persistWarn = (what: string) => (e: unknown) => + console.error(`[config] failed to persist ${what}:`, errMessage(e)); const writeQueue = createKeyedQueue(); const pendingWrites = new Map>(); const persist = (key: string, what: string, op: () => Promise): void => { @@ -285,7 +287,7 @@ export function createMemoryConfigStore( try { listener(id); } catch (e) { - console.error("[config] runtime-selection listener failed:", e); + console.error("[config] runtime-selection listener failed:", errMessage(e)); } } }; diff --git a/src/wiring.ts b/src/wiring.ts index 83540e3d8..9f4d1860a 100644 --- a/src/wiring.ts +++ b/src/wiring.ts @@ -512,13 +512,13 @@ export function buildApp( } }; skillsReady = Promise.all([ - installCatalogs().catch((e) => console.error("[seed] failed to install seed skills:", e)), - deploymentLayerReady.catch((e) => console.error("[seed] deployment layer not ready:", e)), + installCatalogs().catch((e) => console.error("[seed] failed to install seed skills:", errMessage(e))), + deploymentLayerReady.catch((e) => console.error("[seed] deployment layer not ready:", errMessage(e))), ]).then(() => undefined); } else { skillsReady = deploymentLayerReady.then( () => undefined, - (e) => console.error("[seed] deployment layer not ready:", e), + (e) => console.error("[seed] deployment layer not ready:", errMessage(e)), ); } const rateLimitOpts = { maxPerWindow: config.rateLimitPerWindow, windowMs: config.rateLimitWindowMs }; diff --git a/test/admin-scopes-directory.test.ts b/test/admin-scopes-directory.test.ts index dcb280e50..b604750af 100644 --- a/test/admin-scopes-directory.test.ts +++ b/test/admin-scopes-directory.test.ts @@ -16,6 +16,8 @@ function start() { admin: built.admin, auditLog: built.auditLog, sessions: built.sessions, + config: built.config, + environments: built.environments, }); server.listen(0); const base = `http://localhost:${(server.address() as AddressInfo).port}`; @@ -118,3 +120,34 @@ test("scopes without a session label fall back to the org directory (people's na await s.close(); } }); + +test("the admin scope directory exposes named environments and attached scopes", async () => { + const s = start(); + try { + await s.built.app.upsertChannels([ + { channelId: "A", name: "source" }, + { channelId: "B", name: "attached" }, + ]); + await s.built.app.createEnvironment({ scopeId: "channel:A", name: "A-permanent", actorId: "U1" }); + await s.built.app.attachScope({ scopeId: "channel:B", environmentId: "channel:A", actorId: "U1" }); + + const directory = await json(await fetch(`${s.base}/v1/admin/scopes`, { headers: ALICE_ADMIN })); + const byId = new Map(directory.scopes.map((row: any) => [row.scopeId, row])); + assert.deepEqual(directory.environments, [ + { id: "channel:A", name: "A-permanent", ownerActorId: "U1", attachedScopes: ["channel:B"] }, + ]); + assert.equal((byId.get("channel:A") as any).environmentName, "A-permanent"); + assert.deepEqual((byId.get("channel:B") as any).environmentAttachment, { + environmentId: "channel:A", + environmentName: "A-permanent", + }); + + const attached = await json(await fetch(`${s.base}/v1/admin/scopes/channel:B`, { headers: ALICE_ADMIN })); + assert.deepEqual(attached.environmentAttachment, { + environmentId: "channel:A", + environmentName: "A-permanent", + }); + } finally { + await s.close(); + } +});