Add files via upload

This commit is contained in:
公明
2026-07-08 14:19:04 +08:00
committed by GitHub
parent a5d26db5c0
commit 28214254b5
13 changed files with 483 additions and 97 deletions
+2 -1
View File
@@ -11,7 +11,7 @@ Optional integrations that connect CyberStrikeAI with other tools.
### Browser (Chrome / Edge) ### Browser (Chrome / Edge)
- **Path**: `plugins/browser-extension/cyberstrikeai-browser-extension/` - **Path**: `plugins/browser-extension/cyberstrikeai-browser-extension/`
- **Version**: **0.3.5** - **Version**: **0.3.8**
- **Install**: `chrome://extensions/` → Load unpacked → F12 → **CyberStrikeAI** tab - **Install**: `chrome://extensions/` → Load unpacked → F12 → **CyberStrikeAI** tab
- **Package**: `bash package.sh``dist/cyberstrikeai-browser-extension.zip` - **Package**: `bash package.sh``dist/cyberstrikeai-browser-extension.zip`
- **Docs**: `README.zh-CN.md` (full) / `README.md` (summary) - **Docs**: `README.zh-CN.md` (full) / `README.md` (summary)
@@ -20,6 +20,7 @@ Optional integrations that connect CyberStrikeAI with other tools.
| Feature | Description | | Feature | Description |
|---------|-------------| |---------|-------------|
| Token expiry | Remaining time + 30s validate probe; detects server restart / unreachable |
| Capture pause | **● Capturing** / **○ Paused** — stop recording without closing DevTools | | Capture pause | **● Capturing** / **○ Paused** — stop recording without closing DevTools |
| HTTP/1.1 display | Raw HAR in memory; UI + AI prompt normalized (no `:method` pseudo-headers) | | HTTP/1.1 display | Raw HAR in memory; UI + AI prompt normalized (no `:method` pseudo-headers) |
| Collapsible conn bar | Host/Port/Validate collapses after success | | Collapsible conn bar | Host/Port/Validate collapses after success |
@@ -1,6 +1,6 @@
## CyberStrikeAI Browser Extension ## CyberStrikeAI Browser Extension
**Version 0.3.5** — Full docs: **README.zh-CN.md** **Version 0.3.8** — Full docs: **README.zh-CN.md**
Chromium DevTools extension: capture Network traffic and send it to CyberStrikeAI for AI-assisted security testing. Aligned with the Burp Suite plugin. Chromium DevTools extension: capture Network traffic and send it to CyberStrikeAI for AI-assisted security testing. Aligned with the Burp Suite plugin.
@@ -28,7 +28,7 @@ Chromium DevTools extension: capture Network traffic and send it to CyberStrikeA
- **Deferred Markdown** — plain text while streaming; render after done; skip above 100KB - **Deferred Markdown** — plain text while streaming; render after done; skip above 100KB
- **Stop** — abort local stream + server cancel via `conversationId` - **Stop** — abort local stream + server cancel via `conversationId`
- **Latest XHR**, **Copy**, project/role/agent send dialog - **Latest XHR**, **Copy**, project/role/agent send dialog
- Session token + optional host permissions - Session token with **expires_at** tracking, 30s server probe, restart/unreachable detection
### Data limits (no unbounded growth) ### Data limits (no unbounded growth)
@@ -1,6 +1,6 @@
## CyberStrikeAI 浏览器扩展 ## CyberStrikeAI 浏览器扩展
**当前版本:0.3.5** **当前版本:0.3.8**UI 为英文;中文说明见下文)
Chrome / EdgeChromiumDevTools 扩展:在开发者工具中捕获 **Network** 流量,发送到 CyberStrikeAI 进行 AI 辅助安全测试。能力与 Burp Suite 插件对齐,并按生产场景做了性能与体验优化。 Chrome / EdgeChromiumDevTools 扩展:在开发者工具中捕获 **Network** 流量,发送到 CyberStrikeAI 进行 AI 辅助安全测试。能力与 Burp Suite 插件对齐,并按生产场景做了性能与体验优化。
@@ -81,8 +81,13 @@ Cookie: ...
#### 安全与权限 #### 安全与权限
- Token 存 **chrome.storage.session**(关浏览器失效) - Token 存 **chrome.storage.session**(关浏览器失效)
- **optional_host_permissions**Validate 时按需授权 CyberStrikeAI 地址 - 登录后保存 **`expires_at`**,状态栏显示 **剩余时间**(如 `OK · 剩余 11h 30m`
- Markdown iframe 使用 `sandbox` - **不会自动续期**:过期后需重新 Validate(需 Password
- 本地过期检测(30s+ 服务端 `/api/auth/validate` 探测(同周期;切回面板时立即探测)
- 服务不可达时显示 **无法连接服务**;重启后 Token 失效显示 **服务已重启或 Token 已失效**
- **401/403** 时自动清空 Token 并展开连接栏
- Send 前主动校验 Token 有效性
- **optional_host_permissions**Validate 时按需授权
--- ---
@@ -141,6 +146,12 @@ Cookie: ...
**扩展更新后报错 `chrome.runtime.connect` undefined** **扩展更新后报错 `chrome.runtime.connect` undefined**
扩展重载后旧 DevTools 面板上下文失效。请:**关闭 DevTools → 重新加载扩展 → 再开 F12**。 扩展重载后旧 DevTools 面板上下文失效。请:**关闭 DevTools → 重新加载扩展 → 再开 F12**。
**Token 过期会自动刷新吗?**
**不会自动续期**(无 refresh token)。插件会保存 `expires_at`、显示剩余时间;每 30s 向服务端校验,切回 DevTools 时立即校验。服务重启后 session 清空,会提示重新 Validate。
**重启服务后状态还显示 OK**
v0.3.7 起每 30s 探测 `/api/auth/validate`;不可达显示黄色警告,Token 失效则清空并展开连接栏。重载扩展后请关闭 DevTools 再开 F12。
**Request 里为什么曾经有 `:authority`、`:method`** **Request 里为什么曾经有 `:authority`、`:method`**
HTTP/2 伪首部。展示与 AI Prompt 已归一化为 HTTP/1.1;原始 HAR 仍保存在内存 entry 中。 HTTP/2 伪首部。展示与 AI Prompt 已归一化为 HTTP/1.1;原始 HAR 仍保存在内存 entry 中。
@@ -205,8 +216,9 @@ panel/
popup/ popup/
popup.html / popup.js / popup.css # 只读状态 popup.html / popup.js / popup.css # 只读状态
lib/ lib/
auth-session.js # Token 过期检测与剩余时间提示
api.js # 登录、SSE、项目/角色 API api.js # 登录、SSE、项目/角色 API
storage.js # 配置 session token storage.js # 配置 + session token + expires_at
capture.js # HAR 摘要、静态过滤 capture.js # HAR 摘要、静态过滤
http-normalize.js # HTTP/2 → HTTP/1.1 展示/Prompt http-normalize.js # HTTP/2 → HTTP/1.1 展示/Prompt
formatter.js # toPrompt 组装 formatter.js # toPrompt 组装
@@ -10,7 +10,14 @@ function agentModeById(id) {
} }
async function apiFetch(baseUrl, path, options = {}) { async function apiFetch(baseUrl, path, options = {}) {
const res = await fetch(baseUrl + path, options); let res;
try {
res = await fetch(baseUrl + path, options);
} catch (err) {
const e = err instanceof Error ? err : new Error(String(err));
e.network = true;
throw e;
}
const text = await res.text(); const text = await res.text();
let json = null; let json = null;
try { try {
@@ -19,8 +26,9 @@ async function apiFetch(baseUrl, path, options = {}) {
json = null; json = null;
} }
if (!res.ok) { if (!res.ok) {
const err = (json && json.error) || text || `HTTP ${res.status}`; const err = new Error((json && json.error) || text || `HTTP ${res.status}`);
throw new Error(err); err.status = res.status;
throw err;
} }
return json; return json;
} }
@@ -34,12 +42,19 @@ async function loginAndValidate(baseUrl, password, signal) {
}); });
const token = login && login.token; const token = login && login.token;
if (!token) throw new Error('Login response missing token'); if (!token) throw new Error('Login response missing token');
await validateTokenSession(baseUrl, token, signal);
return {
token,
expiresAt: (login && login.expires_at) || '',
};
}
async function validateTokenSession(baseUrl, token, signal) {
await apiFetch(baseUrl, '/api/auth/validate', { await apiFetch(baseUrl, '/api/auth/validate', {
method: 'GET', method: 'GET',
headers: { Authorization: `Bearer ${token}` }, headers: { Authorization: `Bearer ${token}` },
signal, signal,
}); });
return token;
} }
async function fetchProjects(baseUrl, token, signal) { async function fetchProjects(baseUrl, token, signal) {
@@ -48,11 +63,11 @@ async function fetchProjects(baseUrl, token, signal) {
signal, signal,
}); });
const list = (data && data.projects) || []; const list = (data && data.projects) || [];
const out = [{ id: '', label: '()' }]; const out = [{ id: '', label: '(none)' }];
for (const p of list) { for (const p of list) {
if (!p.id) continue; if (!p.id) continue;
let label = p.name || p.id; let label = p.name || p.id;
if (p.status === 'archived') label += ' [已归档]'; if (p.status === 'archived') label += ' [archived]';
out.push({ id: p.id, label }); out.push({ id: p.id, label });
} }
return out; return out;
@@ -64,7 +79,7 @@ async function fetchRoles(baseUrl, token, signal) {
signal, signal,
}); });
const list = (data && data.roles) || []; const list = (data && data.roles) || [];
const out = [{ id: '', label: '默认' }]; const out = [{ id: '', label: 'Default' }];
for (const r of list) { for (const r of list) {
if (r.enabled === false) continue; if (r.enabled === false) continue;
if (!r.name) continue; if (!r.name) continue;
@@ -106,7 +121,9 @@ async function streamTest(baseUrl, token, options, handlers) {
if (!res.ok) { if (!res.ok) {
const t = await res.text(); const t = await res.text();
throw new Error(t || `HTTP ${res.status}`); const err = new Error(t || `HTTP ${res.status}`);
err.status = res.status;
throw err;
} }
const reader = res.body.getReader(); const reader = res.body.getReader();
@@ -0,0 +1,54 @@
/** Session token expiry helpers (aligned with web auth.js patterns). */
const SESSION_TOKEN_EXPIRY_KEY = 'csai_token_expires_at';
/** Warn when remaining session time is below this (ms). */
const TOKEN_WARN_BEFORE_MS = 30 * 60 * 1000;
function parseExpiresAt(iso) {
if (!iso) return null;
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? null : d;
}
function isTokenExpiredByTime(expiresAtIso) {
const d = parseExpiresAt(expiresAtIso);
if (!d) return false;
return d.getTime() <= Date.now();
}
function tokenExpiresWithin(expiresAtIso, withinMs) {
const d = parseExpiresAt(expiresAtIso);
if (!d) return false;
return d.getTime() - Date.now() <= withinMs;
}
function formatTokenExpiryHint(expiresAtIso) {
const d = parseExpiresAt(expiresAtIso);
if (!d) return 'OK (token saved)';
const ms = d.getTime() - Date.now();
if (ms <= 0) return 'Session expired';
const h = Math.floor(ms / 3600000);
const m = Math.floor((ms % 3600000) / 60000);
if (h >= 1) return `OK · ${h}h ${m}m left`;
if (m >= 1) return `OK · ${m}m left`;
return 'OK · expiring soon';
}
function isAuthHttpStatus(status) {
return status === 401 || status === 403;
}
/** fetch() failed before HTTP response (server down, connection refused, etc.). */
function isNetworkFetchError(err) {
if (!err) return false;
if (err.network === true) return true;
if (err.name === 'AbortError') return false;
const msg = String(err.message || err);
return err.name === 'TypeError' && /fetch|network|Failed to fetch/i.test(msg);
}
function attachHttpStatus(err, status) {
if (err && typeof err === 'object') err.status = status;
return err;
}
@@ -14,4 +14,4 @@ const CSAI_LIMITS = {
}; };
const CSAI_DEFAULT_INSTRUCTION = const CSAI_DEFAULT_INSTRUCTION =
'针对该流量做web渗透测试,并输出测试结果,要求:只针对该接口流量做测试,切勿拓展其他接口'; 'Perform web penetration testing on this traffic and output results. Test only this endpoint; do not expand to other APIs.';
@@ -27,42 +27,118 @@ const DEFAULTS = {
showDebugEvents: false, showDebugEvents: false,
}; };
function extensionContextAlive() {
try {
return !!(typeof chrome !== 'undefined' && chrome.runtime && chrome.runtime.id);
} catch (_) {
return false;
}
}
function extensionContextError() {
const err = new Error('Extension context invalidated');
err.name = 'ExtensionContextError';
err.contextInvalidated = true;
return err;
}
function isExtensionContextError(err) {
if (!err) return false;
if (err.contextInvalidated || err.name === 'ExtensionContextError') return true;
return /extension context invalidated/i.test(String(err.message || err));
}
function normalizeStorageError(err) {
if (isExtensionContextError(err)) return extensionContextError();
return err instanceof Error ? err : new Error(String(err));
}
function rejectLastError(reject) {
const msg = chrome.runtime.lastError && chrome.runtime.lastError.message;
if (!msg) return false;
reject(/invalidated/i.test(msg) ? extensionContextError() : new Error(msg));
return true;
}
function localGet(keys) { function localGet(keys) {
return new Promise((resolve) => chrome.storage.local.get(keys, resolve)); return new Promise((resolve, reject) => {
try {
if (!extensionContextAlive()) {
reject(extensionContextError());
return;
}
chrome.storage.local.get(keys, (data) => {
if (rejectLastError(reject)) return;
resolve(data);
});
} catch (err) {
reject(normalizeStorageError(err));
}
});
} }
function localSet(obj) { function localSet(obj) {
return new Promise((resolve) => chrome.storage.local.set(obj, resolve)); return new Promise((resolve, reject) => {
try {
if (!extensionContextAlive()) {
reject(extensionContextError());
return;
}
chrome.storage.local.set(obj, () => {
if (rejectLastError(reject)) return;
resolve();
});
} catch (err) {
reject(normalizeStorageError(err));
}
});
} }
function sessionGet(keys) { function sessionGet(keys) {
return new Promise((resolve) => { return new Promise((resolve, reject) => {
if (!chrome.storage.session) { try {
chrome.storage.local.get(keys, resolve); if (!extensionContextAlive()) {
return; reject(extensionContextError());
return;
}
const store = chrome.storage.session || chrome.storage.local;
store.get(keys, (data) => {
if (rejectLastError(reject)) return;
resolve(data);
});
} catch (err) {
reject(normalizeStorageError(err));
} }
chrome.storage.session.get(keys, resolve);
}); });
} }
function sessionSet(obj) { function sessionSet(obj) {
return new Promise((resolve) => { return new Promise((resolve, reject) => {
if (!chrome.storage.session) { try {
chrome.storage.local.set(obj, resolve); if (!extensionContextAlive()) {
return; reject(extensionContextError());
return;
}
const store = chrome.storage.session || chrome.storage.local;
store.set(obj, () => {
if (rejectLastError(reject)) return;
resolve();
});
} catch (err) {
reject(normalizeStorageError(err));
} }
chrome.storage.session.set(obj, resolve);
}); });
} }
async function loadConfig() { async function loadConfig() {
const data = await localGet(Object.values(STORAGE_KEYS)); const data = await localGet(Object.values(STORAGE_KEYS));
const sess = await sessionGet([SESSION_TOKEN_KEY]); const sess = await sessionGet([SESSION_TOKEN_KEY, SESSION_TOKEN_EXPIRY_KEY]);
return { return {
host: data[STORAGE_KEYS.host] || DEFAULTS.host, host: data[STORAGE_KEYS.host] || DEFAULTS.host,
port: data[STORAGE_KEYS.port] || DEFAULTS.port, port: data[STORAGE_KEYS.port] || DEFAULTS.port,
https: data[STORAGE_KEYS.https] !== false, https: data[STORAGE_KEYS.https] !== false,
token: sess[SESSION_TOKEN_KEY] || '', token: sess[SESSION_TOKEN_KEY] || '',
tokenExpiresAt: sess[SESSION_TOKEN_EXPIRY_KEY] || '',
lastProjectId: data[STORAGE_KEYS.lastProjectId] || '', lastProjectId: data[STORAGE_KEYS.lastProjectId] || '',
lastRole: data[STORAGE_KEYS.lastRole] || '', lastRole: data[STORAGE_KEYS.lastRole] || '',
lastAgentMode: data[STORAGE_KEYS.lastAgentMode] || 'eino_single', lastAgentMode: data[STORAGE_KEYS.lastAgentMode] || 'eino_single',
@@ -88,7 +164,10 @@ async function saveConfig(partial) {
if (partial.renderMarkdown != null) localMap[STORAGE_KEYS.renderMarkdown] = partial.renderMarkdown; if (partial.renderMarkdown != null) localMap[STORAGE_KEYS.renderMarkdown] = partial.renderMarkdown;
if (partial.showDebugEvents != null) localMap[STORAGE_KEYS.showDebugEvents] = partial.showDebugEvents; if (partial.showDebugEvents != null) localMap[STORAGE_KEYS.showDebugEvents] = partial.showDebugEvents;
if (Object.keys(localMap).length) await localSet(localMap); if (Object.keys(localMap).length) await localSet(localMap);
if (partial.token != null) await sessionSet({ [SESSION_TOKEN_KEY]: partial.token }); const sessionMap = {};
if (partial.token != null) sessionMap[SESSION_TOKEN_KEY] = partial.token;
if (partial.tokenExpiresAt != null) sessionMap[SESSION_TOKEN_EXPIRY_KEY] = partial.tokenExpiresAt;
if (Object.keys(sessionMap).length) await sessionSet(sessionMap);
} }
function baseUrlFrom(cfg) { function baseUrlFrom(cfg) {
@@ -98,6 +177,7 @@ function baseUrlFrom(cfg) {
/** Request optional host permission for the configured CyberStrikeAI origin. */ /** Request optional host permission for the configured CyberStrikeAI origin. */
async function ensureHostPermission(baseUrl) { async function ensureHostPermission(baseUrl) {
if (!extensionContextAlive()) throw extensionContextError();
if (!chrome.permissions || !chrome.permissions.request) return; if (!chrome.permissions || !chrome.permissions.request) return;
let origin; let origin;
try { try {
@@ -109,6 +189,6 @@ async function ensureHostPermission(baseUrl) {
if (has) return; if (has) return;
const granted = await chrome.permissions.request({ origins: [origin] }); const granted = await chrome.permissions.request({ origins: [origin] });
if (!granted) { if (!granted) {
throw new Error('需要授权访问 CyberStrikeAI 服务器地址'); throw new Error('Permission required to access the CyberStrikeAI server');
} }
} }
@@ -1,7 +1,7 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "CyberStrikeAI", "name": "CyberStrikeAI",
"version": "0.3.5", "version": "0.3.9",
"description": "Capture browser HTTP traffic and send to CyberStrikeAI for AI-assisted security testing.", "description": "Capture browser HTTP traffic and send to CyberStrikeAI for AI-assisted security testing.",
"devtools_page": "devtools.html", "devtools_page": "devtools.html",
"permissions": ["storage"], "permissions": ["storage"],
@@ -1,5 +1,5 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="zh-CN"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
@@ -8,7 +8,7 @@
</head> </head>
<body> <body>
<div id="ctx-banner" class="ctx-banner hidden" role="alert"> <div id="ctx-banner" class="ctx-banner hidden" role="alert">
扩展已更新或上下文失效,请关闭 DevTools 后重新打开(F12 → CyberStrikeAI 面板)。 Extension updated or context invalidated. Close DevTools and reopen (F12 → CyberStrikeAI panel).
</div> </div>
<header class="toolbar" id="toolbar"> <header class="toolbar" id="toolbar">
<div class="toolbar-row toolbar-row--conn"> <div class="toolbar-row toolbar-row--conn">
@@ -17,7 +17,7 @@
<span class="brand-name">CyberStrikeAI</span> <span class="brand-name">CyberStrikeAI</span>
</div> </div>
<span id="conn-summary" class="conn-summary"></span> <span id="conn-summary" class="conn-summary"></span>
<button id="btn-conn-toggle" type="button" class="btn btn--ghost" aria-expanded="false">连接设置</button> <button id="btn-conn-toggle" type="button" class="btn btn--ghost" aria-expanded="false">Connection</button>
<span id="status" class="status">Not validated</span> <span id="status" class="status">Not validated</span>
</div> </div>
<div id="conn-details" class="conn-details"> <div id="conn-details" class="conn-details">
@@ -50,7 +50,7 @@
<button id="btn-copy" type="button" class="btn btn--ghost">Copy</button> <button id="btn-copy" type="button" class="btn btn--ghost">Copy</button>
<button id="btn-clear" type="button" class="btn btn--ghost">Clear Output</button> <button id="btn-clear" type="button" class="btn btn--ghost">Clear Output</button>
<span class="action-divider"></span> <span class="action-divider"></span>
<button id="btn-capture-toggle" type="button" class="btn btn--capture-on" aria-pressed="true">捕获中</button> <button id="btn-capture-toggle" type="button" class="btn btn--capture-on" aria-pressed="true">Capturing</button>
</div> </div>
<div class="option-group"> <div class="option-group">
<label class="toggle-pill"><input id="filter-api" type="checkbox" checked><span>XHR/Fetch only</span></label> <label class="toggle-pill"><input id="filter-api" type="checkbox" checked><span>XHR/Fetch only</span></label>
@@ -72,7 +72,7 @@
<div class="sidebar-section grow"> <div class="sidebar-section grow">
<div class="sidebar-head"> <div class="sidebar-head">
<strong>Captured Requests</strong> <strong>Captured Requests</strong>
<span id="capture-paused-hint" class="capture-paused-hint hidden">已暂停</span> <span id="capture-paused-hint" class="capture-paused-hint hidden">Paused</span>
<button id="btn-clear-captures" type="button" class="link-btn">Clear</button> <button id="btn-clear-captures" type="button" class="link-btn">Clear</button>
</div> </div>
<div class="search-wrap"> <div class="search-wrap">
@@ -114,24 +114,25 @@
<form method="dialog" id="send-form"> <form method="dialog" id="send-form">
<div class="dialog-header"> <div class="dialog-header">
<img src="../icons/icon48.png" alt="" width="32" height="32" class="dialog-logo"> <img src="../icons/icon48.png" alt="" width="32" height="32" class="dialog-logo">
<h3>发送到 CyberStrikeAI</h3> <h3>Send to CyberStrikeAI</h3>
</div> </div>
<div class="send-row"> <div class="send-row">
<label class="field block">项目 <select id="dlg-project"></select></label> <label class="field block">Project <select id="dlg-project"></select></label>
<label class="field block">角色 <select id="dlg-role"></select></label> <label class="field block">Role <select id="dlg-role"></select></label>
<label class="field block">对话 <select id="dlg-agent"></select></label> <label class="field block">Agent <select id="dlg-agent"></select></label>
</div> </div>
<label class="field block">测试指令(可针对当前流量修改) <label class="field block">Test instruction (editable for this request)
<textarea id="dlg-instruction" rows="6"></textarea> <textarea id="dlg-instruction" rows="6"></textarea>
</label> </label>
<div class="dialog-actions"> <div class="dialog-actions">
<button type="button" id="dlg-cancel" class="btn btn--ghost">取消</button> <button type="button" id="dlg-cancel" class="btn btn--ghost">Cancel</button>
<button type="submit" value="ok" class="btn btn--primary">确定</button> <button type="submit" value="ok" class="btn btn--primary">OK</button>
</div> </div>
</form> </form>
</dialog> </dialog>
<script src="../lib/constants.js"></script> <script src="../lib/constants.js"></script>
<script src="../lib/auth-session.js"></script>
<script src="../lib/storage.js"></script> <script src="../lib/storage.js"></script>
<script src="../lib/http-normalize.js"></script> <script src="../lib/http-normalize.js"></script>
<script src="../lib/formatter.js"></script> <script src="../lib/formatter.js"></script>
@@ -1,7 +1,7 @@
/* global chrome, loadConfig, saveConfig, baseUrlFrom, ensureHostPermission, /* global chrome, loadConfig, saveConfig, baseUrlFrom, ensureHostPermission,
loginAndValidate, fetchCatalogCached, invalidateCatalogCache, streamTest, loginAndValidate, validateTokenSession, fetchCatalogCached, invalidateCatalogCache, streamTest,
cancelByConversationId, extractConversationId, AGENT_MODES, toPrompt, cancelByConversationId, extractConversationId, AGENT_MODES, toPrompt,
defaultInstruction, markdownToHtml, CSAI_LIMITS */ defaultInstruction, markdownToHtml, CSAI_LIMITS, isExtensionContextError */
let config = {}; let config = {};
let entries = []; let entries = [];
@@ -10,6 +10,7 @@ let selectedEntryId = null;
let selectedRunId = null; let selectedRunId = null;
let inspectedTabId = null; let inspectedTabId = null;
let token = ''; let token = '';
let tokenExpiresAt = '';
let bgPort = null; let bgPort = null;
let activeAbort = null; let activeAbort = null;
let activeRunId = null; let activeRunId = null;
@@ -18,6 +19,12 @@ let validating = false;
let captureEnabled = true; let captureEnabled = true;
let searchDebounceTimer = null; let searchDebounceTimer = null;
let connectRetryTimer = null; let connectRetryTimer = null;
let tokenCheckTimer = null;
let probeInFlight = null;
let lastProbeAt = 0;
const TOKEN_TICK_INTERVAL_MS = 30 * 1000;
const PROBE_MIN_GAP_MS = 5000;
let outputFlushScheduled = false; let outputFlushScheduled = false;
let pendingProgressRunId = null; let pendingProgressRunId = null;
let pendingFinalRunId = null; let pendingFinalRunId = null;
@@ -34,6 +41,25 @@ function extensionAlive() {
} }
} }
function stopOnContextLoss() {
if (tokenCheckTimer) {
clearInterval(tokenCheckTimer);
tokenCheckTimer = null;
}
if (connectRetryTimer) {
clearTimeout(connectRetryTimer);
connectRetryTimer = null;
}
bgPort = null;
showContextBanner(true);
}
function onContextLoss(err) {
if (!isExtensionContextError(err)) return false;
stopOnContextLoss();
return true;
}
function showContextBanner(show) { function showContextBanner(show) {
const el = $('ctx-banner'); const el = $('ctx-banner');
if (el) el.classList.toggle('hidden', !show); if (el) el.classList.toggle('hidden', !show);
@@ -56,7 +82,7 @@ function setStatus(text, kind) {
const el = $('status'); const el = $('status');
el.textContent = text; el.textContent = text;
el.className = 'status' + (kind ? ' status-' + kind : ''); el.className = 'status' + (kind ? ' status-' + kind : '');
updateConnSummary(); if (kind !== 'error') updateConnSummary();
} }
function connectionEndpointLabel() { function connectionEndpointLabel() {
@@ -73,7 +99,7 @@ function setConnExpanded(expanded) {
const btn = $('btn-conn-toggle'); const btn = $('btn-conn-toggle');
if (btn) { if (btn) {
btn.setAttribute('aria-expanded', expanded ? 'true' : 'false'); btn.setAttribute('aria-expanded', expanded ? 'true' : 'false');
btn.textContent = expanded ? '收起' : '连接设置'; btn.textContent = expanded ? 'Collapse' : 'Connection';
} }
} }
@@ -81,7 +107,129 @@ function updateConnSummary() {
const summary = $('conn-summary'); const summary = $('conn-summary');
if (!summary) return; if (!summary) return;
summary.textContent = connectionEndpointLabel(); summary.textContent = connectionEndpointLabel();
summary.classList.toggle('conn-summary--ok', !!token); summary.classList.toggle('conn-summary--ok', !!token && !isTokenExpiredByTime(tokenExpiresAt));
}
function refreshAuthStatus() {
if (!token) {
setStatus('Not validated', '');
updateConnSummary();
return;
}
if (isTokenExpiredByTime(tokenExpiresAt)) {
setStatus('Session expired — please Validate again', 'error');
updateConnSummary();
return;
}
const hint = formatTokenExpiryHint(tokenExpiresAt);
const kind = tokenExpiresWithin(tokenExpiresAt, TOKEN_WARN_BEFORE_MS) ? 'warn' : 'ok';
setStatus(hint, kind);
}
async function clearAuthSession(message) {
token = '';
tokenExpiresAt = '';
try {
await saveConfig({ token: '', tokenExpiresAt: '' });
} catch (err) {
if (onContextLoss(err)) return;
throw err;
}
invalidateCatalogCache();
setStatus(message || 'Not validated', message ? 'error' : '');
setConnExpanded(true);
updateConnSummary();
updateSendButtons();
}
async function handleAuthFailure(err, fallbackMessage) {
if (err && isAuthHttpStatus(err.status)) {
await clearAuthSession(fallbackMessage || 'Token invalid or expired — please Validate again');
return true;
}
return false;
}
async function ensureAuthReady() {
if (!token) {
setStatus('Validate first', 'warn');
setConnExpanded(true);
return false;
}
if (isTokenExpiredByTime(tokenExpiresAt)) {
await clearAuthSession('Session expired — please Validate again');
return false;
}
try {
await probeTokenOnServer(true);
return !!token;
} catch (_) {
return false;
}
}
function startTokenWatch() {
if (tokenCheckTimer) clearInterval(tokenCheckTimer);
tokenCheckTimer = setInterval(async () => {
if (!extensionAlive()) {
stopOnContextLoss();
return;
}
if (!token) return;
if (isTokenExpiredByTime(tokenExpiresAt)) {
await clearAuthSession('Session expired — please Validate again');
return;
}
await probeTokenOnServer();
}, TOKEN_TICK_INTERVAL_MS);
}
async function probeTokenOnServer(force) {
if (!extensionAlive()) {
stopOnContextLoss();
return;
}
if (!token || isTokenExpiredByTime(tokenExpiresAt)) return;
const now = Date.now();
if (!force && now - lastProbeAt < PROBE_MIN_GAP_MS) return;
if (probeInFlight) return probeInFlight;
probeInFlight = (async () => {
try {
const baseUrl = baseUrlFrom(await loadConfig());
await validateTokenSession(baseUrl, token);
lastProbeAt = Date.now();
refreshAuthStatus();
} catch (err) {
if (onContextLoss(err)) return;
lastProbeAt = Date.now();
if (await handleAuthFailure(err, 'Server restarted or token invalid — please Validate again')) {
return;
}
if (isNetworkFetchError(err)) {
setStatus('Cannot reach server — confirm CyberStrikeAI is running', 'warn');
updateConnSummary();
return;
}
setStatus('Validation failed: ' + err.message, 'warn');
updateConnSummary();
} finally {
probeInFlight = null;
}
})();
return probeInFlight;
}
function setupAuthProbeHooks() {
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible' && token) {
probeTokenOnServer(true).catch(() => {});
}
});
window.addEventListener('focus', () => {
if (token) probeTokenOnServer(true).catch(() => {});
});
} }
function onConnToggle() { function onConnToggle() {
@@ -96,7 +244,7 @@ function updateCaptureToggleUi() {
btn.classList.toggle('btn--capture-on', captureEnabled); btn.classList.toggle('btn--capture-on', captureEnabled);
btn.classList.toggle('btn--capture-off', !captureEnabled); btn.classList.toggle('btn--capture-off', !captureEnabled);
btn.setAttribute('aria-pressed', captureEnabled ? 'true' : 'false'); btn.setAttribute('aria-pressed', captureEnabled ? 'true' : 'false');
btn.textContent = captureEnabled ? '● 捕获中' : '○ 已暂停'; btn.textContent = captureEnabled ? '● Capturing' : '○ Paused';
} }
const sidebar = $('sidebar'); const sidebar = $('sidebar');
if (sidebar) sidebar.classList.toggle('capture-paused', !captureEnabled); if (sidebar) sidebar.classList.toggle('capture-paused', !captureEnabled);
@@ -105,7 +253,12 @@ function updateCaptureToggleUi() {
} }
async function syncCaptureEnabled() { async function syncCaptureEnabled() {
await saveConfig({ captureEnabled }); try {
await saveConfig({ captureEnabled });
} catch (err) {
if (onContextLoss(err)) return;
throw err;
}
try { try {
await runtimeSendMessage({ type: 'set-capture-enabled', enabled: captureEnabled }); await runtimeSendMessage({ type: 'set-capture-enabled', enabled: captureEnabled });
} catch (_) {} } catch (_) {}
@@ -491,8 +644,14 @@ function setRunConversationId(runId, cid) {
} }
async function initConfig() { async function initConfig() {
config = await loadConfig(); try {
config = await loadConfig();
} catch (err) {
if (onContextLoss(err)) return;
throw err;
}
token = config.token || ''; token = config.token || '';
tokenExpiresAt = config.tokenExpiresAt || '';
$('host').value = config.host; $('host').value = config.host;
$('port').value = config.port; $('port').value = config.port;
$('https').checked = config.https; $('https').checked = config.https;
@@ -500,23 +659,36 @@ async function initConfig() {
captureEnabled = config.captureEnabled !== false; captureEnabled = config.captureEnabled !== false;
$('render-md').checked = config.renderMarkdown; $('render-md').checked = config.renderMarkdown;
$('debug-events').checked = config.showDebugEvents; $('debug-events').checked = config.showDebugEvents;
setStatus(token ? 'OK (token saved, session)' : 'Not validated', token ? 'ok' : '');
if (token && isTokenExpiredByTime(tokenExpiresAt)) {
await clearAuthSession('Session expired — please Validate again');
} else {
refreshAuthStatus();
if (token) {
startTokenWatch();
probeTokenOnServer().catch(() => {});
}
}
setConnExpanded(!token); setConnExpanded(!token);
updateConnSummary();
updateCaptureToggleUi(); updateCaptureToggleUi();
syncCaptureEnabled().catch(() => {}); syncCaptureEnabled().catch(() => {});
updateSendButtons(); updateSendButtons();
} }
async function persistConnection() { async function persistConnection() {
await saveConfig({ try {
host: $('host').value.trim(), await saveConfig({
port: $('port').value.trim(), host: $('host').value.trim(),
https: $('https').checked, port: $('port').value.trim(),
filterApiOnly: $('filter-api').checked, https: $('https').checked,
renderMarkdown: $('render-md').checked, filterApiOnly: $('filter-api').checked,
showDebugEvents: $('debug-events').checked, renderMarkdown: $('render-md').checked,
}); showDebugEvents: $('debug-events').checked,
});
} catch (err) {
onContextLoss(err);
}
} }
async function onValidate() { async function onValidate() {
@@ -532,23 +704,37 @@ async function onValidate() {
$('btn-validate').textContent = 'Cancel'; $('btn-validate').textContent = 'Cancel';
setStatus('Validating...', 'pending'); setStatus('Validating...', 'pending');
await persistConnection(); await persistConnection();
config = await loadConfig(); try {
config = await loadConfig();
} catch (err) {
if (onContextLoss(err)) return;
throw err;
}
const baseUrl = baseUrlFrom(config); const baseUrl = baseUrlFrom(config);
const password = $('password').value; const password = $('password').value;
try { try {
await ensureHostPermission(baseUrl); await ensureHostPermission(baseUrl);
token = await loginAndValidate(baseUrl, password, validateAbort.signal); const auth = await loginAndValidate(baseUrl, password, validateAbort.signal);
await saveConfig({ token }); token = auth.token;
tokenExpiresAt = auth.expiresAt || '';
await saveConfig({ token, tokenExpiresAt });
invalidateCatalogCache(); invalidateCatalogCache();
setStatus('OK (token saved, session)', 'ok'); refreshAuthStatus();
startTokenWatch();
setConnExpanded(false); setConnExpanded(false);
updateSendButtons(); updateSendButtons();
} catch (err) { } catch (err) {
if (onContextLoss(err)) return;
if (err.name === 'AbortError') { if (err.name === 'AbortError') {
setStatus('Cancelled', 'warn'); setStatus('Cancelled', 'warn');
} else { } else {
token = ''; token = '';
await saveConfig({ token: '' }); tokenExpiresAt = '';
try {
await saveConfig({ token: '', tokenExpiresAt: '' });
} catch (saveErr) {
onContextLoss(saveErr);
}
setStatus('Failed: ' + err.message, 'error'); setStatus('Failed: ' + err.message, 'error');
setConnExpanded(true); setConnExpanded(true);
} }
@@ -585,15 +771,16 @@ function fillSelect(sel, items, value) {
async function openSendDialog(entryOverride) { async function openSendDialog(entryOverride) {
const e = entryOverride || selectedEntry(); const e = entryOverride || selectedEntry();
if (!e || !token) return; if (!e) return;
if (!(await ensureAuthReady())) return;
const dlg = $('send-dialog'); const dlg = $('send-dialog');
config = await loadConfig(); config = await loadConfig();
const baseUrl = baseUrlFrom(config); const baseUrl = baseUrlFrom(config);
$('dlg-instruction').value = config.lastInstruction || defaultInstruction(); $('dlg-instruction').value = config.lastInstruction || defaultInstruction();
fillAgentSelect($('dlg-agent'), config.lastAgentMode); fillAgentSelect($('dlg-agent'), config.lastAgentMode);
$('dlg-project').innerHTML = '<option>加载中...</option>'; $('dlg-project').innerHTML = '<option>Loading…</option>';
$('dlg-role').innerHTML = '<option>加载中...</option>'; $('dlg-role').innerHTML = '<option>Loading…</option>';
dlg.showModal(); dlg.showModal();
try { try {
@@ -601,8 +788,12 @@ async function openSendDialog(entryOverride) {
fillSelect($('dlg-project'), projects, config.lastProjectId); fillSelect($('dlg-project'), projects, config.lastProjectId);
fillSelect($('dlg-role'), roles, config.lastRole); fillSelect($('dlg-role'), roles, config.lastRole);
} catch (err) { } catch (err) {
fillSelect($('dlg-project'), [{ id: '', label: '(加载失败)' }], ''); if (await handleAuthFailure(err, 'Token invalid or expired — please Validate again')) {
fillSelect($('dlg-role'), [{ id: '', label: '默认' }], ''); dlg.close();
return;
}
fillSelect($('dlg-project'), [{ id: '', label: '(load failed)' }], '');
fillSelect($('dlg-role'), [{ id: '', label: 'Default' }], '');
} }
dlg.dataset.entryId = e.id; dlg.dataset.entryId = e.id;
@@ -611,22 +802,27 @@ async function openSendDialog(entryOverride) {
async function onSendConfirmed() { async function onSendConfirmed() {
const entryId = $('send-dialog').dataset.entryId; const entryId = $('send-dialog').dataset.entryId;
const e = entries.find((x) => x.id === entryId) || selectedEntry(); const e = entries.find((x) => x.id === entryId) || selectedEntry();
if (!e || !token) return; if (!e || !(await ensureAuthReady())) return;
const instruction = $('dlg-instruction').value.trim() || defaultInstruction(); const instruction = $('dlg-instruction').value.trim() || defaultInstruction();
const projectId = $('dlg-project').value || ''; const projectId = $('dlg-project').value || '';
const role = $('dlg-role').value || ''; const role = $('dlg-role').value || '';
const agentMode = $('dlg-agent').value || 'eino_single'; const agentMode = $('dlg-agent').value || 'eino_single';
await saveConfig({ try {
lastInstruction: instruction, await saveConfig({
lastProjectId: projectId, lastInstruction: instruction,
lastRole: role, lastProjectId: projectId,
lastAgentMode: agentMode, lastRole: role,
}); lastAgentMode: agentMode,
});
} catch (err) {
if (onContextLoss(err)) return;
throw err;
}
const modeLabel = (AGENT_MODES.find((m) => m.id === agentMode) || {}).label || agentMode; const modeLabel = (AGENT_MODES.find((m) => m.id === agentMode) || {}).label || agentMode;
const roleLabel = role || '默认'; const roleLabel = role || 'Default';
const run = createRun(e, modeLabel + ' · ' + roleLabel); const run = createRun(e, modeLabel + ' · ' + roleLabel);
activeRunId = run.id; activeRunId = run.id;
prependRunItem(run); prependRunItem(run);
@@ -665,6 +861,7 @@ async function onSendConfirmed() {
appendToRun(run.id, 'progress', '\n[info] Local stream stopped.\n'); appendToRun(run.id, 'progress', '\n[info] Local stream stopped.\n');
setRunStatus(run.id, 'cancelled'); setRunStatus(run.id, 'cancelled');
} else { } else {
await handleAuthFailure(err, 'Token invalid or expired — please Validate again');
appendToRun(run.id, 'progress', '\n[error] ' + err.message + '\n'); appendToRun(run.id, 'progress', '\n[error] ' + err.message + '\n');
setRunStatus(run.id, 'error'); setRunStatus(run.id, 'error');
} }
@@ -677,7 +874,7 @@ function handleStreamEvent(runId, type, message, ev) {
const debug = $('debug-events').checked; const debug = $('debug-events').checked;
switch (type) { switch (type) {
case 'response_start': case 'response_start':
appendToRun(runId, 'progress', '\n\n[主回复]\n'); appendToRun(runId, 'progress', '\n\n[Main reply]\n');
break; break;
case 'response_delta': case 'response_delta':
if (message) appendToRun(runId, 'final', message); if (message) appendToRun(runId, 'final', message);
@@ -686,7 +883,7 @@ function handleStreamEvent(runId, type, message, ev) {
if (message) appendToRun(runId, 'final', message); if (message) appendToRun(runId, 'final', message);
break; break;
case 'eino_agent_reply_stream_start': case 'eino_agent_reply_stream_start':
appendToRun(runId, 'progress', '\n\n[子代理回复]\n'); appendToRun(runId, 'progress', '\n\n[Sub-agent reply]\n');
break; break;
case 'eino_agent_reply_stream_delta': case 'eino_agent_reply_stream_delta':
if (message) appendToRun(runId, 'progress', message); if (message) appendToRun(runId, 'progress', message);
@@ -869,7 +1066,8 @@ async function clearCaptures() {
} }
async function sendLatestXhr() { async function sendLatestXhr() {
if (inspectedTabId == null || !token) return; if (inspectedTabId == null) return;
if (!(await ensureAuthReady())) return;
const resp = await runtimeSendMessage({ const resp = await runtimeSendMessage({
type: 'get-latest-api', type: 'get-latest-api',
tabId: inspectedTabId, tabId: inspectedTabId,
@@ -941,6 +1139,7 @@ $('send-form').addEventListener('submit', (ev) => {
$('dlg-cancel').addEventListener('click', () => $('send-dialog').close()); $('dlg-cancel').addEventListener('click', () => $('send-dialog').close());
setupTabs(); setupTabs();
setupAuthProbeHooks();
initConfig().then(() => { initConfig().then(() => {
if (extensionAlive()) connectBackground(); if (extensionAlive()) connectBackground();
else showContextBanner(true); else showContextBanner(true);
@@ -1,5 +1,5 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="zh-CN"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<link rel="stylesheet" href="popup.css"> <link rel="stylesheet" href="popup.css">
@@ -13,19 +13,21 @@
</div> </div>
</header> </header>
<div id="conn-status" class="conn-status conn-status--idle">加载中</div> <div id="conn-status" class="conn-status conn-status--idle">Loading</div>
<p class="hint">主工作区在 DevTools 面板:按 <strong>F12</strong><strong>CyberStrikeAI</strong></p> <p class="hint">Main workspace is in DevTools: press <strong>F12</strong><strong>CyberStrikeAI</strong></p>
<ul class="tips"> <ul class="tips">
<li>连接配置与 Validate 请在 DevTools 面板完成</li> <li>Connection and Validate are in the DevTools panel</li>
<li>默认只捕获 XHR/Fetch</li> <li>Captures XHR/Fetch by default</li>
<li>Test History 可回看多次发送</li> <li>Test History stores recent sends</li>
<li><strong>Latest XHR</strong> 快速发送最近 API 请求</li> <li><strong>Latest XHR</strong> sends the most recent API request</li>
</ul> </ul>
<script src="../lib/constants.js"></script> <script src="../lib/constants.js"></script>
<script src="../lib/auth-session.js"></script>
<script src="../lib/storage.js"></script> <script src="../lib/storage.js"></script>
<script src="../lib/api.js"></script>
<script src="popup.js"></script> <script src="popup.js"></script>
</body> </body>
</html> </html>
@@ -12,17 +12,37 @@ async function renderPopup() {
try { try {
const cfg = await loadConfig(); const cfg = await loadConfig();
const endpoint = baseUrlFrom(cfg); const endpoint = baseUrlFrom(cfg);
if (cfg.token) { if (!cfg.token) {
statusEl.className = 'conn-status conn-status--ok';
statusEl.textContent = `已连接 ${endpoint}`;
} else {
statusEl.className = 'conn-status conn-status--idle'; statusEl.className = 'conn-status conn-status--idle';
statusEl.textContent = `未验证 · 目标 ${endpoint}`; statusEl.textContent = `Not validated · ${endpoint}`;
return;
}
if (isTokenExpiredByTime(cfg.tokenExpiresAt)) {
statusEl.className = 'conn-status conn-status--idle';
statusEl.textContent = `Session expired · ${endpoint}`;
return;
}
try {
await validateTokenSession(endpoint, cfg.token);
statusEl.className = 'conn-status conn-status--ok';
statusEl.textContent = `${formatTokenExpiryHint(cfg.tokenExpiresAt)} · ${endpoint}`;
} catch (err) {
if (isAuthHttpStatus(err.status)) {
statusEl.className = 'conn-status conn-status--idle';
statusEl.textContent = `Server restarted or token invalid · ${endpoint}`;
} else if (isNetworkFetchError(err)) {
statusEl.className = 'conn-status conn-status--idle';
statusEl.textContent = `Cannot reach server · ${endpoint}`;
} else {
statusEl.className = 'conn-status conn-status--idle';
statusEl.textContent = `Validation failed · ${endpoint}`;
}
} }
} catch (_) { } catch (_) {
statusEl.className = 'conn-status conn-status--idle'; statusEl.className = 'conn-status conn-status--idle';
statusEl.textContent = '无法读取配置'; statusEl.textContent = 'Cannot read config';
} }
} }
renderPopup(); renderPopup().catch(() => {});