Harden alarm push auth and session export ZIP paths.

Ignore hub alarms before auth-ok, stop reconnecting after auth-fail, serialize sticky delivery with dispose on reset, and sanitize ZIP entry names against path traversal.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-06 14:45:35 +08:00
parent 92279c1220
commit 5598b3661c
5 changed files with 148 additions and 56 deletions

View file

@ -148,6 +148,8 @@ function startAlarmPushClient(options) {
return () => {}; return () => {};
} }
let closed = false; let closed = false;
let haltReconnect = false;
let subscribed = false;
let socket = null; let socket = null;
let reconnectTimer; let reconnectTimer;
let attempt = 0; let attempt = 0;
@ -159,7 +161,7 @@ function startAlarmPushClient(options) {
} }
}; };
const scheduleReconnect = (reason) => { const scheduleReconnect = (reason) => {
if (closed) if (closed || haltReconnect)
return; return;
clearReconnect(); clearReconnect();
const delay = Math.min(60000, baseDelay * 2 ** Math.min(attempt, 5)); const delay = Math.min(60000, baseDelay * 2 ** Math.min(attempt, 5));
@ -173,9 +175,10 @@ function startAlarmPushClient(options) {
}, delay); }, delay);
}; };
const connect = () => { const connect = () => {
if (closed) if (closed || haltReconnect)
return; return;
clearReconnect(); clearReconnect();
subscribed = false;
setPhase(attempt > 0 ? "reconnecting" : "connecting", wsUrl, { detail: "dialing" }); setPhase(attempt > 0 ? "reconnecting" : "connecting", wsUrl, { detail: "dialing" });
try { try {
socket = new WS(wsUrl); socket = new WS(wsUrl);
@ -199,6 +202,7 @@ function startAlarmPushClient(options) {
const type = String(msg.type ?? "").toLowerCase(); const type = String(msg.type ?? "").toLowerCase();
if (type === "auth-ok") { if (type === "auth-ok") {
attempt = 0; attempt = 0;
subscribed = true;
const now = Date.now(); const now = Date.now();
setPhase("connected", wsUrl, { setPhase("connected", wsUrl, {
detail: String(msg.user ?? "ok"), detail: String(msg.user ?? "ok"),
@ -210,14 +214,23 @@ function startAlarmPushClient(options) {
} }
if (type === "auth-fail") { if (type === "auth-fail") {
const err = String(msg.error ?? "auth_failed"); const err = String(msg.error ?? "auth_failed");
log.error?.("netxops alarm-push: auth failed (%s)", err); log.error?.("netxops alarm-push: auth failed (%s) — not reconnecting until settings/token change", err);
subscribed = false;
haltReconnect = true;
clearReconnect();
setPhase("auth_failed", wsUrl, { detail: err, lastError: err }); setPhase("auth_failed", wsUrl, { detail: err, lastError: err });
socket?.close(); try {
socket?.close();
} catch {}
return; return;
} }
if (type === "pong") if (type === "pong")
return; return;
if (type === "event" && String(msg.event ?? "") === "netx.alarm") { if (type === "event" && String(msg.event ?? "") === "netx.alarm") {
if (!subscribed) {
log.warn?.("netxops alarm-push: ignoring alarm before auth-ok");
return;
}
const payload = msg.payload && typeof msg.payload === "object" ? msg.payload : {}; const payload = msg.payload && typeof msg.payload === "object" ? msg.payload : {};
Promise.resolve(options.onAlarm(payload)).catch((error) => { Promise.resolve(options.onAlarm(payload)).catch((error) => {
log.warn?.("netxops alarm-push: handler failed:", error); log.warn?.("netxops alarm-push: handler failed:", error);
@ -226,7 +239,8 @@ function startAlarmPushClient(options) {
}); });
socket.addEventListener("close", () => { socket.addEventListener("close", () => {
socket = null; socket = null;
if (!closed) subscribed = false;
if (!closed && !haltReconnect)
scheduleReconnect("socket_closed"); scheduleReconnect("socket_closed");
}); });
socket.addEventListener("error", () => {}); socket.addEventListener("error", () => {});
@ -356,28 +370,47 @@ var PRESET_ID = "netxops";
var PERMISSION_PRESET = "default"; var PERMISSION_PRESET = "default";
var TITLE = "Netx 关键告警"; var TITLE = "Netx 关键告警";
var sticky = null; var sticky = null;
var deliveryChain = Promise.resolve();
function resolveWorkspacePath() { function resolveWorkspacePath() {
const fromEnv = process.env.DSH_HOME?.trim(); const fromEnv = process.env.DSH_HOME?.trim();
const home = fromEnv && fromEnv.length > 0 ? fromEnv : join(homedir(), ".dsh"); const home = fromEnv && fromEnv.length > 0 ? fromEnv : join(homedir(), ".dsh");
return join(home, "workspaces", "netxops-alarms"); return join(home, "workspaces", "netxops-alarms");
} }
async function deliverAlarmToSession(ctx, payload, lang = "zh") { async function disposeSticky(handle) {
const prompt = formatAlarmPrompt(payload, lang); if (!handle)
const agents = ctx.agents;
if (!agents || typeof agents.create !== "function") {
ctx.logger.warn("netxops alarm-push: ctx.agents unavailable — enable a profile that mounts agents to receive alarms in a DSH session");
return; return;
} try {
if (sticky?.agent && typeof sticky.agent.followup === "function") { await handle.dispose?.();
try { } catch {}
await followup(ctx, sticky.agent, prompt); }
async function deliverAlarmToSession(ctx, payload, lang = "zh") {
const run = async () => {
const prompt = formatAlarmPrompt(payload, lang);
const agents = ctx.agents;
if (!agents || typeof agents.create !== "function") {
ctx.logger.warn("netxops alarm-push: ctx.agents unavailable — enable a profile that mounts agents to receive alarms in a DSH session");
return; return;
} catch (error) {
ctx.logger.warn("netxops alarm-push: sticky followup failed, recreating session: %s", error);
sticky = null;
} }
} if (sticky?.agent && typeof sticky.agent.followup === "function") {
await createStickySession(ctx, prompt); try {
await followup(ctx, sticky.agent, prompt);
return;
} catch (error) {
ctx.logger.warn("netxops alarm-push: sticky followup failed, recreating session: %s", error);
const previous = sticky;
sticky = null;
await disposeSticky(previous);
}
}
await createStickySession(ctx, prompt);
};
const next = deliveryChain.then(run, run);
deliveryChain = next.then(() => {
return;
}, () => {
return;
});
await next;
} }
async function followup(ctx, agent, prompt) { async function followup(ctx, agent, prompt) {
let createUserMessage; let createUserMessage;
@ -387,7 +420,7 @@ async function followup(ctx, agent, prompt) {
if (typeof createUserMessage !== "function") if (typeof createUserMessage !== "function")
throw new Error("createUserMessage missing"); throw new Error("createUserMessage missing");
const summary = typeof mod.boundContextSummary === "function" ? mod.boundContextSummary("netx key alarm") : "netx key alarm"; const summary = typeof mod.boundContextSummary === "function" ? mod.boundContextSummary("netx key alarm") : "netx key alarm";
agent.followup(createUserMessage({ await Promise.resolve(agent.followup(createUserMessage({
content: [{ type: "text", text: prompt }], content: [{ type: "text", text: prompt }],
source: { source: {
kind: "webhook", kind: "webhook",
@ -396,7 +429,7 @@ async function followup(ctx, agent, prompt) {
form: "notice", form: "notice",
summary summary
} }
})); })));
} catch (error) { } catch (error) {
const anyAgent = agent; const anyAgent = agent;
if (typeof anyAgent.prompt === "function") { if (typeof anyAgent.prompt === "function") {
@ -455,7 +488,11 @@ async function createStickySession(ctx, prompt) {
sessionTitle.rename(handle.agent.session, TITLE); sessionTitle.rename(handle.agent.session, TITLE);
} }
await followup(ctx, handle.agent, prompt); await followup(ctx, handle.agent, prompt);
sticky = { sessionId, agent: handle.agent }; sticky = {
sessionId,
agent: handle.agent,
dispose: typeof handle.dispose === "function" ? () => handle.dispose() : undefined
};
ctx.logger.info("netxops alarm-push: opened sticky session %s", sessionId); ctx.logger.info("netxops alarm-push: opened sticky session %s", sessionId);
} catch (error) { } catch (error) {
try { try {
@ -465,7 +502,9 @@ async function createStickySession(ctx, prompt) {
} }
} }
function resetAlarmSession() { function resetAlarmSession() {
const previous = sticky;
sticky = null; sticky = null;
disposeSticky(previous);
} }
// src/netx/capability-groups.ts // src/netx/capability-groups.ts
@ -1619,7 +1658,9 @@ async function getSessionsExportStatus(ctx, signal) {
} }
} }
function safePathSegment(id) { function safePathSegment(id) {
return id.replace(/[^A-Za-z0-9_-]/g, "_"); const base = String(id || "").replace(/\\/g, "/").split("/").pop() ?? "";
const cleaned = base.replace(/[^A-Za-z0-9._-]/g, "_").replace(/^\.+/, "");
return cleaned || "unnamed";
} }
function sessionsExportZipFilename(exportedAt = new Date, host = osHostname()) { function sessionsExportZipFilename(exportedAt = new Date, host = osHostname()) {
const stamp = [ const stamp = [
@ -1670,7 +1711,7 @@ async function* sessionsExportEntries(ctx, signal) {
skipped.push({ id, reason: "no stored artifact" }); skipped.push({ id, reason: "no stored artifact" });
continue; continue;
} }
const filename = raw.filename && raw.filename.length > 0 ? raw.filename : "session.jsonl"; const filename = safePathSegment(raw.filename && raw.filename.length > 0 ? raw.filename : "session.jsonl") || "session.jsonl";
const path = `sessions/${safePathSegment(id)}/${filename}`; const path = `sessions/${safePathSegment(id)}/${filename}`;
artifactEntries.push({ path, content: raw.content }); artifactEntries.push({ path, content: raw.content });
included.push({ included.push({

View file

@ -148,6 +148,10 @@ export function startAlarmPushClient(options: AlarmPushClientOptions): () => voi
} }
let closed = false let closed = false
/** After auth-fail, do not reconnect until the client is disposed/restarted. */
let haltReconnect = false
/** Only forward alarms after a successful auth-ok on the current socket. */
let subscribed = false
let socket: WebSocket | null = null let socket: WebSocket | null = null
let reconnectTimer: ReturnType<typeof setTimeout> | undefined let reconnectTimer: ReturnType<typeof setTimeout> | undefined
let attempt = 0 let attempt = 0
@ -161,7 +165,7 @@ export function startAlarmPushClient(options: AlarmPushClientOptions): () => voi
} }
const scheduleReconnect = (reason: string): void => { const scheduleReconnect = (reason: string): void => {
if (closed) return if (closed || haltReconnect) return
clearReconnect() clearReconnect()
const delay = Math.min(60_000, baseDelay * (2 ** Math.min(attempt, 5))) const delay = Math.min(60_000, baseDelay * (2 ** Math.min(attempt, 5)))
attempt += 1 attempt += 1
@ -173,8 +177,9 @@ export function startAlarmPushClient(options: AlarmPushClientOptions): () => voi
} }
const connect = (): void => { const connect = (): void => {
if (closed) return if (closed || haltReconnect) return
clearReconnect() clearReconnect()
subscribed = false
setPhase(attempt > 0 ? 'reconnecting' : 'connecting', wsUrl, { detail: 'dialing' }) setPhase(attempt > 0 ? 'reconnecting' : 'connecting', wsUrl, { detail: 'dialing' })
try { try {
socket = new WS(wsUrl) socket = new WS(wsUrl)
@ -200,6 +205,7 @@ export function startAlarmPushClient(options: AlarmPushClientOptions): () => voi
const type = String(msg.type ?? '').toLowerCase() const type = String(msg.type ?? '').toLowerCase()
if (type === 'auth-ok') { if (type === 'auth-ok') {
attempt = 0 attempt = 0
subscribed = true
const now = Date.now() const now = Date.now()
setPhase('connected', wsUrl, { setPhase('connected', wsUrl, {
detail: String(msg.user ?? 'ok'), detail: String(msg.user ?? 'ok'),
@ -211,13 +217,24 @@ export function startAlarmPushClient(options: AlarmPushClientOptions): () => voi
} }
if (type === 'auth-fail') { if (type === 'auth-fail') {
const err = String(msg.error ?? 'auth_failed') const err = String(msg.error ?? 'auth_failed')
log.error?.('netxops alarm-push: auth failed (%s)', err) log.error?.('netxops alarm-push: auth failed (%s) — not reconnecting until settings/token change', err)
subscribed = false
haltReconnect = true
clearReconnect()
setPhase('auth_failed', wsUrl, { detail: err, lastError: err }) setPhase('auth_failed', wsUrl, { detail: err, lastError: err })
socket?.close() try {
socket?.close()
} catch {
// ignore
}
return return
} }
if (type === 'pong') return if (type === 'pong') return
if (type === 'event' && String(msg.event ?? '') === 'netx.alarm') { if (type === 'event' && String(msg.event ?? '') === 'netx.alarm') {
if (!subscribed) {
log.warn?.('netxops alarm-push: ignoring alarm before auth-ok')
return
}
const payload = msg.payload && typeof msg.payload === 'object' const payload = msg.payload && typeof msg.payload === 'object'
? msg.payload as KeyAlarmPayload ? msg.payload as KeyAlarmPayload
: {} : {}
@ -229,7 +246,8 @@ export function startAlarmPushClient(options: AlarmPushClientOptions): () => voi
socket.addEventListener('close', () => { socket.addEventListener('close', () => {
socket = null socket = null
if (!closed) scheduleReconnect('socket_closed') subscribed = false
if (!closed && !haltReconnect) scheduleReconnect('socket_closed')
}) })
socket.addEventListener('error', () => { socket.addEventListener('error', () => {

View file

@ -19,9 +19,12 @@ interface StickyHandle {
sessionId: string sessionId: string
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Agent type varies by DSH version // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Agent type varies by DSH version
agent: any agent: any
dispose?: () => Promise<void> | void
} }
let sticky: StickyHandle | null = null let sticky: StickyHandle | null = null
/** Serialize create/followup so concurrent alarms cannot open multiple sticky sessions. */
let deliveryChain: Promise<void> = Promise.resolve()
function resolveWorkspacePath(): string { function resolveWorkspacePath(): string {
const fromEnv = process.env.DSH_HOME?.trim() const fromEnv = process.env.DSH_HOME?.trim()
@ -29,6 +32,15 @@ function resolveWorkspacePath(): string {
return join(home, 'workspaces', 'netxops-alarms') return join(home, 'workspaces', 'netxops-alarms')
} }
async function disposeSticky(handle: StickyHandle | null): Promise<void> {
if (!handle) return
try {
await handle.dispose?.()
} catch {
// Best-effort teardown; the process may already have dropped the agent.
}
}
/** /**
* Open or reuse a Netx Ops session and append the alarm as a user followup. * Open or reuse a Netx Ops session and append the alarm as a user followup.
* @param ctx - host cordis context (may lack session services on minimal profiles). * @param ctx - host cordis context (may lack session services on minimal profiles).
@ -40,27 +52,35 @@ export async function deliverAlarmToSession(
payload: KeyAlarmPayload, payload: KeyAlarmPayload,
lang = 'zh', lang = 'zh',
): Promise<void> { ): Promise<void> {
const prompt = formatAlarmPrompt(payload, lang) const run = async (): Promise<void> => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any const prompt = formatAlarmPrompt(payload, lang)
const agents = (ctx as any).agents // eslint-disable-next-line @typescript-eslint/no-explicit-any
if (!agents || typeof agents.create !== 'function') { const agents = (ctx as any).agents
ctx.logger.warn( if (!agents || typeof agents.create !== 'function') {
'netxops alarm-push: ctx.agents unavailable — enable a profile that mounts agents to receive alarms in a DSH session', ctx.logger.warn(
) 'netxops alarm-push: ctx.agents unavailable — enable a profile that mounts agents to receive alarms in a DSH session',
return )
}
if (sticky?.agent && typeof sticky.agent.followup === 'function') {
try {
await followup(ctx, sticky.agent, prompt)
return return
} catch (error) {
ctx.logger.warn('netxops alarm-push: sticky followup failed, recreating session: %s', error)
sticky = null
} }
if (sticky?.agent && typeof sticky.agent.followup === 'function') {
try {
await followup(ctx, sticky.agent, prompt)
return
} catch (error) {
ctx.logger.warn('netxops alarm-push: sticky followup failed, recreating session: %s', error)
const previous = sticky
sticky = null
await disposeSticky(previous)
}
}
await createStickySession(ctx, prompt)
} }
await createStickySession(ctx, prompt) const next = deliveryChain.then(run, run)
deliveryChain = next.then(() => undefined, () => undefined)
await next
} }
async function followup(ctx: Context, agent: { followup: (msg: unknown) => unknown }, prompt: string): Promise<void> { async function followup(ctx: Context, agent: { followup: (msg: unknown) => unknown }, prompt: string): Promise<void> {
@ -78,7 +98,7 @@ async function followup(ctx: Context, agent: { followup: (msg: unknown) => unkno
const summary = typeof mod.boundContextSummary === 'function' const summary = typeof mod.boundContextSummary === 'function'
? mod.boundContextSummary('netx key alarm') ? mod.boundContextSummary('netx key alarm')
: 'netx key alarm' : 'netx key alarm'
agent.followup(createUserMessage({ await Promise.resolve(agent.followup(createUserMessage({
content: [{ type: 'text', text: prompt }], content: [{ type: 'text', text: prompt }],
source: { source: {
kind: 'webhook', kind: 'webhook',
@ -87,7 +107,7 @@ async function followup(ctx: Context, agent: { followup: (msg: unknown) => unkno
form: 'notice', form: 'notice',
summary, summary,
}, },
})) })))
} catch (error) { } catch (error) {
// Fallback: some hosts accept a plain text followup helper on agents. // Fallback: some hosts accept a plain text followup helper on agents.
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -162,7 +182,11 @@ async function createStickySession(ctx: Context, prompt: string): Promise<void>
sessionTitle.rename(handle.agent.session, TITLE) sessionTitle.rename(handle.agent.session, TITLE)
} }
await followup(ctx, handle.agent, prompt) await followup(ctx, handle.agent, prompt)
sticky = { sessionId, agent: handle.agent } sticky = {
sessionId,
agent: handle.agent,
dispose: typeof handle.dispose === 'function' ? () => handle.dispose() : undefined,
}
ctx.logger.info('netxops alarm-push: opened sticky session %s', sessionId) ctx.logger.info('netxops alarm-push: opened sticky session %s', sessionId)
} catch (error) { } catch (error) {
try { try {
@ -174,7 +198,9 @@ async function createStickySession(ctx: Context, prompt: string): Promise<void>
} }
} }
/** Drop the sticky handle (tests / dispose). */ /** Drop and dispose the sticky handle (config restart / plugin dispose / tests). */
export function resetAlarmSession(): void { export function resetAlarmSession(): void {
const previous = sticky
sticky = null sticky = null
void disposeSticky(previous)
} }

View file

@ -162,11 +162,14 @@ export async function getSessionsExportStatus(
} }
/** /**
* Sanitize one path segment for ZIP entry names. * Sanitize one path segment for ZIP entry names (no separators / traversal).
* @param id - raw session id or hostname fragment. * Keeps a single basename; dots in extensions like `.jsonl` are allowed.
* @param id - raw session id, hostname fragment, or artifact filename.
*/ */
export function safePathSegment(id: string): string { export function safePathSegment(id: string): string {
return id.replace(/[^A-Za-z0-9_-]/g, '_') const base = String(id || '').replace(/\\/g, '/').split('/').pop() ?? ''
const cleaned = base.replace(/[^A-Za-z0-9._-]/g, '_').replace(/^\.+/, '')
return cleaned || 'unnamed'
} }
/** /**
@ -246,7 +249,9 @@ export async function* sessionsExportEntries(
skipped.push({ id, reason: 'no stored artifact' }) skipped.push({ id, reason: 'no stored artifact' })
continue continue
} }
const filename = raw.filename && raw.filename.length > 0 ? raw.filename : 'session.jsonl' const filename = safePathSegment(
raw.filename && raw.filename.length > 0 ? raw.filename : 'session.jsonl',
) || 'session.jsonl'
const path = `sessions/${safePathSegment(id)}/${filename}` const path = `sessions/${safePathSegment(id)}/${filename}`
artifactEntries.push({ path, content: raw.content }) artifactEntries.push({ path, content: raw.content })
included.push({ included.push({

View file

@ -7,8 +7,10 @@ import {
sessionsExportZipFilename, sessionsExportZipFilename,
} from '../src/netx/session-export.ts' } from '../src/netx/session-export.ts'
test('safePathSegment strips path separators and dots', () => { test('safePathSegment strips path separators and keeps filename extensions', () => {
assert.equal(safePathSegment('../a/b'), '___a_b') assert.equal(safePathSegment('../a/b'), 'b')
assert.equal(safePathSegment('..\\evil.jsonl'), 'evil.jsonl')
assert.equal(safePathSegment('session.jsonl'), 'session.jsonl')
assert.equal(safePathSegment('netxops-alarm-abc'), 'netxops-alarm-abc') assert.equal(safePathSegment('netxops-alarm-abc'), 'netxops-alarm-abc')
}) })