Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
23 changes: 23 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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.",
},
],
},
},
);
62 changes: 62 additions & 0 deletions plugins/admin/public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -3987,6 +4011,13 @@ <h1>Governance</h1>
<span>Scope</span><strong id="governance-scope-label">Organization</strong>
</div>
</div>
<aside class="environment-notice hidden" id="environment-notice" role="status">
<div>
<strong id="environment-notice-title"></strong>
<p id="environment-notice-detail"></p>
</div>
<button type="button" id="environment-notice-open">Open environment</button>
</aside>
<section class="governance-overview" id="governance-overview" aria-labelledby="governance-overview-title">
<div class="governance-overview-head">
<h2 id="governance-overview-title">Effective state</h2>
Expand Down Expand Up @@ -5717,6 +5748,7 @@ <h2 id="governance-review-title">Confirm governance change</h2>
});

let scopeDir = null;
let environmentDir = [];
let scopeDirNote = "Loading scopes…";
async function loadScopeDirectory() {
const r = await api("GET", "/api/scopes");
Expand All @@ -5728,6 +5760,7 @@ <h2 id="governance-review-title">Confirm governance change</h2>
}
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");
Expand Down Expand Up @@ -6244,6 +6277,19 @@ <h2 id="governance-review-title">Confirm governance change</h2>
);
}
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;
Expand All @@ -6259,6 +6305,7 @@ <h2 id="governance-review-title">Confirm governance change</h2>
);
return;
}
renderEnvironmentNotice(r.data);
renderGovernanceOverview(r.data);
syncGovernanceSectionNav();
loadedCommandPolicyPresent = r.data.commandPolicy != null;
Expand Down Expand Up @@ -11451,6 +11498,21 @@ <h2 id="governance-review-title">Confirm governance change</h2>
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) => {
Expand Down
10 changes: 5 additions & 5 deletions plugins/admin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" });
}
}
Expand Down Expand Up @@ -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" });
}
}
Expand Down Expand Up @@ -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" });
}
}
Expand Down Expand Up @@ -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" });
});
});
Expand Down Expand Up @@ -402,7 +402,7 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise<void>
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;
Expand Down
13 changes: 13 additions & 0 deletions plugins/admin/test/environments.test.ts
Original file line number Diff line number Diff line change
@@ -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/);
});
2 changes: 1 addition & 1 deletion plugins/auth/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
});
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 ${req.method ?? "?"} ${(req.url ?? "?").split("?")[0]}:`, String(err));
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
if (!res.headersSent) json(res, 500, { error: "internal_error" });
else res.end();
});
Expand Down
2 changes: 1 addition & 1 deletion plugins/chassis/src/branding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export function createBrandingCache(fetchBranding: () => Promise<OrgBranding>):
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;
Expand Down
2 changes: 1 addition & 1 deletion plugins/portal/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -812,7 +812,7 @@

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 ${req.method ?? "?"} ${(req.url ?? "?").split("?")[0]}:`, String(err));
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
if (!res.headersSent) json(res, 500, { error: "internal_error" });
else res.end();
});
Expand Down
4 changes: 2 additions & 2 deletions plugins/web-ui/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1918,7 +1918,7 @@

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 ${req.method ?? "?"} ${req.url ?? "?"}:`, String(err));
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
if (!res.headersSent) json(res, 502, { error: "bad_gateway", message: "upstream error" });
else res.end();
});
Expand All @@ -1942,7 +1942,7 @@
});
})
.catch((err: unknown) => {
console.error("[web-ui] failed to start:", err);
console.error("[web-ui] failed to start:", String(err));
process.exit(1);
});
}
3 changes: 2 additions & 1 deletion src/admin/postgres-audit-log.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>): AuditEvent {
return {
Expand Down Expand Up @@ -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));
},
Expand Down
3 changes: 2 additions & 1 deletion src/admin/postgres-metrics-sink.ts
Original file line number Diff line number Diff line change
@@ -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<keyof TurnMetricSample & string>[] = [
["ts", "ts", "BIGINT", "number", true],
Expand Down Expand Up @@ -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),
};
Expand Down
3 changes: 2 additions & 1 deletion src/admin/scoped-event-sink.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -162,7 +163,7 @@ export function createPostgresEventSink<E>(cfg: PostgresEventSinkConfig<E>): Pos
const s = input as Record<string, unknown>;
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);
},
Expand Down
Loading