mirror of
https://github.com/hansjone/netxops.git
synced 2026-10-12 09:00:47 +08:00
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:
parent
92279c1220
commit
5598b3661c
5 changed files with 148 additions and 56 deletions
89
lib/index.js
89
lib/index.js
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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', () => {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue