From f5338bf3a62950845fe10c1eeaa918c20604d2ec Mon Sep 17 00:00:00 2001 From: oratis Date: Fri, 21 Aug 2026 00:09:00 +0800 Subject: [PATCH] obs(cloud): dedupe redactEmail, classify mailer + startup logs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-ups to #363, found while verifying that PR in production. - redactEmail existed twice with different behavior: src/log.ts kept 1 char via indexOf, src/web/mailer.ts:74 kept 2 via lastIndexOf and also handled a trailing "@". The stricter one is now the only one — it moves into log.ts beside the other redactors (so the dependency runs web → log, not the reverse) and mailer.ts re-exports it for its own callers. - mailer.ts's four log sites were still bare console.error, so a successful send filed at the same level as a failed one: outcome=success is INFO, skipped_no_key is WARNING, both send_failed paths are ERROR. Recipients were already redacted; that is unchanged. - Startup path had no severity at all on Cloud Run — cli.ts's two serve lines, soul/git.ts's git-missing warning, and entrypoint.sh's three echoes. The shell ones get a log_line helper mirroring src/log.ts: JSON under K_SERVICE, plain text otherwise. Verified: /health-serving revision 00021 shows [sweep]/[orchestrator] at INFO and reviewer email redacted, so this only covers what #363 missed. npm test 1637 pass / 0 fail; typecheck + api-contract clean. Co-Authored-By: Claude Opus 5 --- deploy/entrypoint.sh | 18 +++++++++++++++--- src/cli.ts | 5 +++-- src/log.test.ts | 11 +++++++---- src/log.ts | 14 +++++++++----- src/soul/git.ts | 3 ++- src/web/mailer.ts | 23 +++++++++-------------- 6 files changed, 45 insertions(+), 29 deletions(-) diff --git a/deploy/entrypoint.sh b/deploy/entrypoint.sh index 31d4ef7e..54e0303f 100755 --- a/deploy/entrypoint.sh +++ b/deploy/entrypoint.sh @@ -11,18 +11,30 @@ # otherwise each cold start re-births a fresh demo soul. set -e +# Match the app's log discipline (src/log.ts): on Cloud Run (K_SERVICE) emit +# one-line JSON so Cloud Logging lifts the severity instead of filing these as +# unclassified; plain text anywhere else. Messages here are fixed strings with +# no quotes or backslashes, so no JSON escaping is needed. +log_line() { + if [ -n "$K_SERVICE" ]; then + printf '{"severity":"%s","message":"%s"}\n' "$1" "$2" + else + echo "$2" + fi +} + export LISA_HOME="${LISA_HOME:-/data}" mkdir -p "$LISA_HOME" # Born? isBorn() resolves true once the soul seed exists under $LISA_HOME. if node -e "import('./dist/soul/store.js').then(m=>m.isBorn()).then(b=>process.exit(b?0:1)).catch(e=>{console.error(e);process.exit(1)})"; then - echo "[cloud] soul already present — skipping birth" + log_line INFO "[cloud] soul already present — skipping birth" else - echo "[cloud] birthing the demo soul (model ${LISA_MODEL:-default})…" + log_line INFO "[cloud] birthing the demo soul (model ${LISA_MODEL:-default})…" # `lisa birth` validates that a provider key for the model is configured (any of # ANTHROPIC_API_KEY / ZHIPU_API_KEY / OPENAI_API_KEY / … per the model). If none # is set it exits non-zero and the first web visitor gets the birth ritual. - node dist/cli.js birth || echo "[cloud] birth skipped/failed — first visitor will see the birth ritual" + node dist/cli.js birth || log_line WARNING "[cloud] birth skipped/failed — first visitor will see the birth ritual" fi exec node dist/cli.js serve --web --port "${PORT:-8080}" --host 0.0.0.0 diff --git a/src/cli.ts b/src/cli.ts index 3b1acb11..ddcda77f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -9,6 +9,7 @@ import { dirname, resolve as resolvePath } from "node:path"; // in ~/.lisa/config.env. import { configureProxyFromEnv } from "./proxy-bootstrap.js"; configureProxyFromEnv({ log: (m) => console.error(m) }); +import { logInfo } from "./log.js"; import { runAgent } from "./agent.js"; import { buildApprovalCallback, DEFAULT_MUTATING_TOOLS, DEFAULT_MUTATING_ACTIONS } from "./approval.js"; import { CONFIG_ENV_PATH, loadConfigEnv } from "./env.js"; @@ -570,9 +571,9 @@ async function main(): Promise { // Print the real bind address — this used to claim "localhost" while // Node was actually listening on every interface. const display = isLoopbackAddress(args.host) ? "localhost" : args.host; - console.error(`Lisa web UI listening on http://${display}:${args.port} (bound to ${args.host})`); + logInfo(`Lisa web UI listening on http://${display}:${args.port} (bound to ${args.host})`); if (!isLoopbackAddress(args.host)) { - console.error( + logInfo( `[web] non-loopback bind: requests from other machines must present LISA_WEB_TOKEN ` + `(open http://${args.host}:${args.port}/?token= once per device)`, ); diff --git a/src/log.test.ts b/src/log.test.ts index ab95b528..8ca7c08b 100644 --- a/src/log.test.ts +++ b/src/log.test.ts @@ -16,8 +16,11 @@ test("redactId keeps a prefix+suffix for correlation, never the middle", () => { assert.equal(redactId(""), ""); }); -test("redactEmail keeps first char + domain only", () => { - assert.equal(redactEmail("alice@example.com"), "a***@example.com"); - assert.equal(redactEmail("not-an-address"), "…"); - assert.equal(redactEmail("@nouser.com"), "…"); +test("redactEmail drops the identifying local part, keeps the domain", () => { + assert.equal(redactEmail("alice.smith@example.com"), "al***@example.com"); + assert.equal(redactEmail("a@b.co"), "a***@b.co"); + // Anything that isn't an address must not fall through as-is. + assert.equal(redactEmail("not-an-address"), "***"); + assert.equal(redactEmail("@nouser.com"), "***"); + assert.equal(redactEmail("trailing@"), "***"); }); diff --git a/src/log.ts b/src/log.ts index 31fc6373..b8f8b5ed 100644 --- a/src/log.ts +++ b/src/log.ts @@ -60,9 +60,13 @@ export function redactId(id: string): string { return `${id.slice(0, 4)}…${id.slice(-4)}`; } -/** `alice@example.com` → `a***@example.com`; a non-address becomes `…`. */ -export function redactEmail(email: string): string { - const at = email.indexOf("@"); - if (at <= 0) return "…"; - return `${email[0]}***@${email.slice(at + 1)}`; +/** + * `alice.smith@example.com` → `al***@example.com`. The local part is the + * identifying half, so it goes; the domain stays whole because that's what you + * group by when delivery breaks. Anything that isn't an address becomes `***`. + */ +export function redactEmail(addr: string): string { + const at = addr.lastIndexOf("@"); + if (at <= 0 || at === addr.length - 1) return "***"; + return `${addr.slice(0, Math.min(2, at))}***@${addr.slice(at + 1)}`; } diff --git a/src/soul/git.ts b/src/soul/git.ts index c867a229..6929492e 100644 --- a/src/soul/git.ts +++ b/src/soul/git.ts @@ -13,6 +13,7 @@ import { AsyncLocalStorage } from "node:async_hooks"; import path from "node:path"; import { pathExists } from "../fs-utils.js"; import { isCloud } from "../edition.js"; +import { logWarn } from "../log.js"; import { withFileLock } from "./lock.js"; import { soulDir } from "./paths.js"; @@ -137,7 +138,7 @@ async function runGit(args: string[]): Promise { */ export async function initSoulRepo(): Promise { if (!(await checkGitAvailable())) { - console.warn("[soul-git] git not available; soul history disabled"); + logWarn("[soul-git] git not available; soul history disabled"); return; } if (!(await pathExists(soulDir()))) return; diff --git a/src/web/mailer.ts b/src/web/mailer.ts index 4b76c760..fbb777f9 100644 --- a/src/web/mailer.ts +++ b/src/web/mailer.ts @@ -38,6 +38,7 @@ * * Env: RESEND_API_KEY, LISA_MAIL_FROM (default "LISA "). */ +import { logInfo, logWarn, logError, redactEmail } from "../log.js"; export interface MailResult { sent: boolean; @@ -66,16 +67,10 @@ export function mailerConfig(env: Record = process.e // ── log hygiene ───────────────────────────────────────────────────────────── -/** - * `alice.smith@example.com` → `al***@example.com`. The local part is the - * identifying half, so it goes; the domain stays whole because that's what you - * group by when delivery breaks. - */ -export function redactEmail(addr: string): string { - const at = addr.lastIndexOf("@"); - if (at <= 0 || at === addr.length - 1) return "***"; - return `${addr.slice(0, Math.min(2, at))}***@${addr.slice(at + 1)}`; -} +// The canonical implementation lives in ../log.js beside the other log-line +// redactors; re-exported here because callers of this module reach for it as +// part of the mail surface. +export { redactEmail }; // ── HTML templating (hand-rolled: no MJML, no react-email, no deps) ───────── // Email HTML is 1998 HTML — tables, inline styles, no flexbox, no