mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-07-10 14:28:43 +02:00
Add files via upload
This commit is contained in:
+2
-1
@@ -11,7 +11,7 @@ Optional integrations that connect CyberStrikeAI with other tools.
|
||||
### Browser (Chrome / Edge)
|
||||
|
||||
- **Path**: `plugins/browser-extension/cyberstrikeai-browser-extension/`
|
||||
- **Version**: **0.3.5**
|
||||
- **Version**: **0.3.8**
|
||||
- **Install**: `chrome://extensions/` → Load unpacked → F12 → **CyberStrikeAI** tab
|
||||
- **Package**: `bash package.sh` → `dist/cyberstrikeai-browser-extension.zip`
|
||||
- **Docs**: `README.zh-CN.md` (full) / `README.md` (summary)
|
||||
@@ -20,6 +20,7 @@ Optional integrations that connect CyberStrikeAI with other tools.
|
||||
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| Token expiry | Remaining time + 30s validate probe; detects server restart / unreachable |
|
||||
| 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) |
|
||||
| Collapsible conn bar | Host/Port/Validate collapses after success |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
## 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.
|
||||
|
||||
@@ -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
|
||||
- **Stop** — abort local stream + server cancel via `conversationId`
|
||||
- **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)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
## CyberStrikeAI 浏览器扩展
|
||||
|
||||
**当前版本:0.3.5**
|
||||
**当前版本:0.3.8**(UI 为英文;中文说明见下文)
|
||||
|
||||
Chrome / Edge(Chromium)DevTools 扩展:在开发者工具中捕获 **Network** 流量,发送到 CyberStrikeAI 进行 AI 辅助安全测试。能力与 Burp Suite 插件对齐,并按生产场景做了性能与体验优化。
|
||||
|
||||
@@ -81,8 +81,13 @@ Cookie: ...
|
||||
#### 安全与权限
|
||||
|
||||
- Token 存 **chrome.storage.session**(关浏览器失效)
|
||||
- **optional_host_permissions**:Validate 时按需授权 CyberStrikeAI 地址
|
||||
- Markdown iframe 使用 `sandbox`
|
||||
- 登录后保存 **`expires_at`**,状态栏显示 **剩余时间**(如 `OK · 剩余 11h 30m`)
|
||||
- **不会自动续期**:过期后需重新 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?**
|
||||
扩展重载后旧 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`?**
|
||||
HTTP/2 伪首部。展示与 AI Prompt 已归一化为 HTTP/1.1;原始 HAR 仍保存在内存 entry 中。
|
||||
|
||||
@@ -205,8 +216,9 @@ panel/
|
||||
popup/
|
||||
popup.html / popup.js / popup.css # 只读状态
|
||||
lib/
|
||||
auth-session.js # Token 过期检测与剩余时间提示
|
||||
api.js # 登录、SSE、项目/角色 API
|
||||
storage.js # 配置与 session token
|
||||
storage.js # 配置 + session token + expires_at
|
||||
capture.js # HAR 摘要、静态过滤
|
||||
http-normalize.js # HTTP/2 → HTTP/1.1 展示/Prompt
|
||||
formatter.js # toPrompt 组装
|
||||
|
||||
Vendored
BIN
Binary file not shown.
@@ -10,7 +10,14 @@ function agentModeById(id) {
|
||||
}
|
||||
|
||||
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();
|
||||
let json = null;
|
||||
try {
|
||||
@@ -19,8 +26,9 @@ async function apiFetch(baseUrl, path, options = {}) {
|
||||
json = null;
|
||||
}
|
||||
if (!res.ok) {
|
||||
const err = (json && json.error) || text || `HTTP ${res.status}`;
|
||||
throw new Error(err);
|
||||
const err = new Error((json && json.error) || text || `HTTP ${res.status}`);
|
||||
err.status = res.status;
|
||||
throw err;
|
||||
}
|
||||
return json;
|
||||
}
|
||||
@@ -34,12 +42,19 @@ async function loginAndValidate(baseUrl, password, signal) {
|
||||
});
|
||||
const token = login && login.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', {
|
||||
method: 'GET',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
signal,
|
||||
});
|
||||
return token;
|
||||
}
|
||||
|
||||
async function fetchProjects(baseUrl, token, signal) {
|
||||
@@ -48,11 +63,11 @@ async function fetchProjects(baseUrl, token, signal) {
|
||||
signal,
|
||||
});
|
||||
const list = (data && data.projects) || [];
|
||||
const out = [{ id: '', label: '(无)' }];
|
||||
const out = [{ id: '', label: '(none)' }];
|
||||
for (const p of list) {
|
||||
if (!p.id) continue;
|
||||
let label = p.name || p.id;
|
||||
if (p.status === 'archived') label += ' [已归档]';
|
||||
if (p.status === 'archived') label += ' [archived]';
|
||||
out.push({ id: p.id, label });
|
||||
}
|
||||
return out;
|
||||
@@ -64,7 +79,7 @@ async function fetchRoles(baseUrl, token, signal) {
|
||||
signal,
|
||||
});
|
||||
const list = (data && data.roles) || [];
|
||||
const out = [{ id: '', label: '默认' }];
|
||||
const out = [{ id: '', label: 'Default' }];
|
||||
for (const r of list) {
|
||||
if (r.enabled === false) continue;
|
||||
if (!r.name) continue;
|
||||
@@ -106,7 +121,9 @@ async function streamTest(baseUrl, token, options, handlers) {
|
||||
|
||||
if (!res.ok) {
|
||||
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();
|
||||
|
||||
@@ -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 =
|
||||
'针对该流量做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,
|
||||
};
|
||||
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
return new Promise((resolve) => {
|
||||
if (!chrome.storage.session) {
|
||||
chrome.storage.local.get(keys, resolve);
|
||||
return;
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
if (!extensionContextAlive()) {
|
||||
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) {
|
||||
return new Promise((resolve) => {
|
||||
if (!chrome.storage.session) {
|
||||
chrome.storage.local.set(obj, resolve);
|
||||
return;
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
if (!extensionContextAlive()) {
|
||||
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() {
|
||||
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 {
|
||||
host: data[STORAGE_KEYS.host] || DEFAULTS.host,
|
||||
port: data[STORAGE_KEYS.port] || DEFAULTS.port,
|
||||
https: data[STORAGE_KEYS.https] !== false,
|
||||
token: sess[SESSION_TOKEN_KEY] || '',
|
||||
tokenExpiresAt: sess[SESSION_TOKEN_EXPIRY_KEY] || '',
|
||||
lastProjectId: data[STORAGE_KEYS.lastProjectId] || '',
|
||||
lastRole: data[STORAGE_KEYS.lastRole] || '',
|
||||
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.showDebugEvents != null) localMap[STORAGE_KEYS.showDebugEvents] = partial.showDebugEvents;
|
||||
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) {
|
||||
@@ -98,6 +177,7 @@ function baseUrlFrom(cfg) {
|
||||
|
||||
/** Request optional host permission for the configured CyberStrikeAI origin. */
|
||||
async function ensureHostPermission(baseUrl) {
|
||||
if (!extensionContextAlive()) throw extensionContextError();
|
||||
if (!chrome.permissions || !chrome.permissions.request) return;
|
||||
let origin;
|
||||
try {
|
||||
@@ -109,6 +189,6 @@ async function ensureHostPermission(baseUrl) {
|
||||
if (has) return;
|
||||
const granted = await chrome.permissions.request({ origins: [origin] });
|
||||
if (!granted) {
|
||||
throw new Error('需要授权访问 CyberStrikeAI 服务器地址');
|
||||
throw new Error('Permission required to access the CyberStrikeAI server');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "CyberStrikeAI",
|
||||
"version": "0.3.5",
|
||||
"version": "0.3.9",
|
||||
"description": "Capture browser HTTP traffic and send to CyberStrikeAI for AI-assisted security testing.",
|
||||
"devtools_page": "devtools.html",
|
||||
"permissions": ["storage"],
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
@@ -8,7 +8,7 @@
|
||||
</head>
|
||||
<body>
|
||||
<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>
|
||||
<header class="toolbar" id="toolbar">
|
||||
<div class="toolbar-row toolbar-row--conn">
|
||||
@@ -17,7 +17,7 @@
|
||||
<span class="brand-name">CyberStrikeAI</span>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
<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-clear" type="button" class="btn btn--ghost">Clear Output</button>
|
||||
<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 class="option-group">
|
||||
<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-head">
|
||||
<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>
|
||||
</div>
|
||||
<div class="search-wrap">
|
||||
@@ -114,24 +114,25 @@
|
||||
<form method="dialog" id="send-form">
|
||||
<div class="dialog-header">
|
||||
<img src="../icons/icon48.png" alt="" width="32" height="32" class="dialog-logo">
|
||||
<h3>发送到 CyberStrikeAI</h3>
|
||||
<h3>Send to CyberStrikeAI</h3>
|
||||
</div>
|
||||
<div class="send-row">
|
||||
<label class="field block">项目 <select id="dlg-project"></select></label>
|
||||
<label class="field block">角色 <select id="dlg-role"></select></label>
|
||||
<label class="field block">对话 <select id="dlg-agent"></select></label>
|
||||
<label class="field block">Project <select id="dlg-project"></select></label>
|
||||
<label class="field block">Role <select id="dlg-role"></select></label>
|
||||
<label class="field block">Agent <select id="dlg-agent"></select></label>
|
||||
</div>
|
||||
<label class="field block">测试指令(可针对当前流量修改)
|
||||
<label class="field block">Test instruction (editable for this request)
|
||||
<textarea id="dlg-instruction" rows="6"></textarea>
|
||||
</label>
|
||||
<div class="dialog-actions">
|
||||
<button type="button" id="dlg-cancel" class="btn btn--ghost">取消</button>
|
||||
<button type="submit" value="ok" class="btn btn--primary">确定</button>
|
||||
<button type="button" id="dlg-cancel" class="btn btn--ghost">Cancel</button>
|
||||
<button type="submit" value="ok" class="btn btn--primary">OK</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<script src="../lib/constants.js"></script>
|
||||
<script src="../lib/auth-session.js"></script>
|
||||
<script src="../lib/storage.js"></script>
|
||||
<script src="../lib/http-normalize.js"></script>
|
||||
<script src="../lib/formatter.js"></script>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* global chrome, loadConfig, saveConfig, baseUrlFrom, ensureHostPermission,
|
||||
loginAndValidate, fetchCatalogCached, invalidateCatalogCache, streamTest,
|
||||
loginAndValidate, validateTokenSession, fetchCatalogCached, invalidateCatalogCache, streamTest,
|
||||
cancelByConversationId, extractConversationId, AGENT_MODES, toPrompt,
|
||||
defaultInstruction, markdownToHtml, CSAI_LIMITS */
|
||||
defaultInstruction, markdownToHtml, CSAI_LIMITS, isExtensionContextError */
|
||||
|
||||
let config = {};
|
||||
let entries = [];
|
||||
@@ -10,6 +10,7 @@ let selectedEntryId = null;
|
||||
let selectedRunId = null;
|
||||
let inspectedTabId = null;
|
||||
let token = '';
|
||||
let tokenExpiresAt = '';
|
||||
let bgPort = null;
|
||||
let activeAbort = null;
|
||||
let activeRunId = null;
|
||||
@@ -18,6 +19,12 @@ let validating = false;
|
||||
let captureEnabled = true;
|
||||
let searchDebounceTimer = 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 pendingProgressRunId = 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) {
|
||||
const el = $('ctx-banner');
|
||||
if (el) el.classList.toggle('hidden', !show);
|
||||
@@ -56,7 +82,7 @@ function setStatus(text, kind) {
|
||||
const el = $('status');
|
||||
el.textContent = text;
|
||||
el.className = 'status' + (kind ? ' status-' + kind : '');
|
||||
updateConnSummary();
|
||||
if (kind !== 'error') updateConnSummary();
|
||||
}
|
||||
|
||||
function connectionEndpointLabel() {
|
||||
@@ -73,7 +99,7 @@ function setConnExpanded(expanded) {
|
||||
const btn = $('btn-conn-toggle');
|
||||
if (btn) {
|
||||
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');
|
||||
if (!summary) return;
|
||||
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() {
|
||||
@@ -96,7 +244,7 @@ function updateCaptureToggleUi() {
|
||||
btn.classList.toggle('btn--capture-on', captureEnabled);
|
||||
btn.classList.toggle('btn--capture-off', !captureEnabled);
|
||||
btn.setAttribute('aria-pressed', captureEnabled ? 'true' : 'false');
|
||||
btn.textContent = captureEnabled ? '● 捕获中' : '○ 已暂停';
|
||||
btn.textContent = captureEnabled ? '● Capturing' : '○ Paused';
|
||||
}
|
||||
const sidebar = $('sidebar');
|
||||
if (sidebar) sidebar.classList.toggle('capture-paused', !captureEnabled);
|
||||
@@ -105,7 +253,12 @@ function updateCaptureToggleUi() {
|
||||
}
|
||||
|
||||
async function syncCaptureEnabled() {
|
||||
await saveConfig({ captureEnabled });
|
||||
try {
|
||||
await saveConfig({ captureEnabled });
|
||||
} catch (err) {
|
||||
if (onContextLoss(err)) return;
|
||||
throw err;
|
||||
}
|
||||
try {
|
||||
await runtimeSendMessage({ type: 'set-capture-enabled', enabled: captureEnabled });
|
||||
} catch (_) {}
|
||||
@@ -491,8 +644,14 @@ function setRunConversationId(runId, cid) {
|
||||
}
|
||||
|
||||
async function initConfig() {
|
||||
config = await loadConfig();
|
||||
try {
|
||||
config = await loadConfig();
|
||||
} catch (err) {
|
||||
if (onContextLoss(err)) return;
|
||||
throw err;
|
||||
}
|
||||
token = config.token || '';
|
||||
tokenExpiresAt = config.tokenExpiresAt || '';
|
||||
$('host').value = config.host;
|
||||
$('port').value = config.port;
|
||||
$('https').checked = config.https;
|
||||
@@ -500,23 +659,36 @@ async function initConfig() {
|
||||
captureEnabled = config.captureEnabled !== false;
|
||||
$('render-md').checked = config.renderMarkdown;
|
||||
$('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);
|
||||
updateConnSummary();
|
||||
updateCaptureToggleUi();
|
||||
syncCaptureEnabled().catch(() => {});
|
||||
updateSendButtons();
|
||||
}
|
||||
|
||||
async function persistConnection() {
|
||||
await saveConfig({
|
||||
host: $('host').value.trim(),
|
||||
port: $('port').value.trim(),
|
||||
https: $('https').checked,
|
||||
filterApiOnly: $('filter-api').checked,
|
||||
renderMarkdown: $('render-md').checked,
|
||||
showDebugEvents: $('debug-events').checked,
|
||||
});
|
||||
try {
|
||||
await saveConfig({
|
||||
host: $('host').value.trim(),
|
||||
port: $('port').value.trim(),
|
||||
https: $('https').checked,
|
||||
filterApiOnly: $('filter-api').checked,
|
||||
renderMarkdown: $('render-md').checked,
|
||||
showDebugEvents: $('debug-events').checked,
|
||||
});
|
||||
} catch (err) {
|
||||
onContextLoss(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function onValidate() {
|
||||
@@ -532,23 +704,37 @@ async function onValidate() {
|
||||
$('btn-validate').textContent = 'Cancel';
|
||||
setStatus('Validating...', 'pending');
|
||||
await persistConnection();
|
||||
config = await loadConfig();
|
||||
try {
|
||||
config = await loadConfig();
|
||||
} catch (err) {
|
||||
if (onContextLoss(err)) return;
|
||||
throw err;
|
||||
}
|
||||
const baseUrl = baseUrlFrom(config);
|
||||
const password = $('password').value;
|
||||
try {
|
||||
await ensureHostPermission(baseUrl);
|
||||
token = await loginAndValidate(baseUrl, password, validateAbort.signal);
|
||||
await saveConfig({ token });
|
||||
const auth = await loginAndValidate(baseUrl, password, validateAbort.signal);
|
||||
token = auth.token;
|
||||
tokenExpiresAt = auth.expiresAt || '';
|
||||
await saveConfig({ token, tokenExpiresAt });
|
||||
invalidateCatalogCache();
|
||||
setStatus('OK (token saved, session)', 'ok');
|
||||
refreshAuthStatus();
|
||||
startTokenWatch();
|
||||
setConnExpanded(false);
|
||||
updateSendButtons();
|
||||
} catch (err) {
|
||||
if (onContextLoss(err)) return;
|
||||
if (err.name === 'AbortError') {
|
||||
setStatus('Cancelled', 'warn');
|
||||
} else {
|
||||
token = '';
|
||||
await saveConfig({ token: '' });
|
||||
tokenExpiresAt = '';
|
||||
try {
|
||||
await saveConfig({ token: '', tokenExpiresAt: '' });
|
||||
} catch (saveErr) {
|
||||
onContextLoss(saveErr);
|
||||
}
|
||||
setStatus('Failed: ' + err.message, 'error');
|
||||
setConnExpanded(true);
|
||||
}
|
||||
@@ -585,15 +771,16 @@ function fillSelect(sel, items, value) {
|
||||
|
||||
async function openSendDialog(entryOverride) {
|
||||
const e = entryOverride || selectedEntry();
|
||||
if (!e || !token) return;
|
||||
if (!e) return;
|
||||
if (!(await ensureAuthReady())) return;
|
||||
const dlg = $('send-dialog');
|
||||
config = await loadConfig();
|
||||
const baseUrl = baseUrlFrom(config);
|
||||
|
||||
$('dlg-instruction').value = config.lastInstruction || defaultInstruction();
|
||||
fillAgentSelect($('dlg-agent'), config.lastAgentMode);
|
||||
$('dlg-project').innerHTML = '<option>加载中...</option>';
|
||||
$('dlg-role').innerHTML = '<option>加载中...</option>';
|
||||
$('dlg-project').innerHTML = '<option>Loading…</option>';
|
||||
$('dlg-role').innerHTML = '<option>Loading…</option>';
|
||||
dlg.showModal();
|
||||
|
||||
try {
|
||||
@@ -601,8 +788,12 @@ async function openSendDialog(entryOverride) {
|
||||
fillSelect($('dlg-project'), projects, config.lastProjectId);
|
||||
fillSelect($('dlg-role'), roles, config.lastRole);
|
||||
} catch (err) {
|
||||
fillSelect($('dlg-project'), [{ id: '', label: '(加载失败)' }], '');
|
||||
fillSelect($('dlg-role'), [{ id: '', label: '默认' }], '');
|
||||
if (await handleAuthFailure(err, 'Token invalid or expired — please Validate again')) {
|
||||
dlg.close();
|
||||
return;
|
||||
}
|
||||
fillSelect($('dlg-project'), [{ id: '', label: '(load failed)' }], '');
|
||||
fillSelect($('dlg-role'), [{ id: '', label: 'Default' }], '');
|
||||
}
|
||||
|
||||
dlg.dataset.entryId = e.id;
|
||||
@@ -611,22 +802,27 @@ async function openSendDialog(entryOverride) {
|
||||
async function onSendConfirmed() {
|
||||
const entryId = $('send-dialog').dataset.entryId;
|
||||
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 projectId = $('dlg-project').value || '';
|
||||
const role = $('dlg-role').value || '';
|
||||
const agentMode = $('dlg-agent').value || 'eino_single';
|
||||
|
||||
await saveConfig({
|
||||
lastInstruction: instruction,
|
||||
lastProjectId: projectId,
|
||||
lastRole: role,
|
||||
lastAgentMode: agentMode,
|
||||
});
|
||||
try {
|
||||
await saveConfig({
|
||||
lastInstruction: instruction,
|
||||
lastProjectId: projectId,
|
||||
lastRole: role,
|
||||
lastAgentMode: agentMode,
|
||||
});
|
||||
} catch (err) {
|
||||
if (onContextLoss(err)) return;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const modeLabel = (AGENT_MODES.find((m) => m.id === agentMode) || {}).label || agentMode;
|
||||
const roleLabel = role || '默认';
|
||||
const roleLabel = role || 'Default';
|
||||
const run = createRun(e, modeLabel + ' · ' + roleLabel);
|
||||
activeRunId = run.id;
|
||||
prependRunItem(run);
|
||||
@@ -665,6 +861,7 @@ async function onSendConfirmed() {
|
||||
appendToRun(run.id, 'progress', '\n[info] Local stream stopped.\n');
|
||||
setRunStatus(run.id, 'cancelled');
|
||||
} else {
|
||||
await handleAuthFailure(err, 'Token invalid or expired — please Validate again');
|
||||
appendToRun(run.id, 'progress', '\n[error] ' + err.message + '\n');
|
||||
setRunStatus(run.id, 'error');
|
||||
}
|
||||
@@ -677,7 +874,7 @@ function handleStreamEvent(runId, type, message, ev) {
|
||||
const debug = $('debug-events').checked;
|
||||
switch (type) {
|
||||
case 'response_start':
|
||||
appendToRun(runId, 'progress', '\n\n[主回复]\n');
|
||||
appendToRun(runId, 'progress', '\n\n[Main reply]\n');
|
||||
break;
|
||||
case 'response_delta':
|
||||
if (message) appendToRun(runId, 'final', message);
|
||||
@@ -686,7 +883,7 @@ function handleStreamEvent(runId, type, message, ev) {
|
||||
if (message) appendToRun(runId, 'final', message);
|
||||
break;
|
||||
case 'eino_agent_reply_stream_start':
|
||||
appendToRun(runId, 'progress', '\n\n[子代理回复]\n');
|
||||
appendToRun(runId, 'progress', '\n\n[Sub-agent reply]\n');
|
||||
break;
|
||||
case 'eino_agent_reply_stream_delta':
|
||||
if (message) appendToRun(runId, 'progress', message);
|
||||
@@ -869,7 +1066,8 @@ async function clearCaptures() {
|
||||
}
|
||||
|
||||
async function sendLatestXhr() {
|
||||
if (inspectedTabId == null || !token) return;
|
||||
if (inspectedTabId == null) return;
|
||||
if (!(await ensureAuthReady())) return;
|
||||
const resp = await runtimeSendMessage({
|
||||
type: 'get-latest-api',
|
||||
tabId: inspectedTabId,
|
||||
@@ -941,6 +1139,7 @@ $('send-form').addEventListener('submit', (ev) => {
|
||||
$('dlg-cancel').addEventListener('click', () => $('send-dialog').close());
|
||||
|
||||
setupTabs();
|
||||
setupAuthProbeHooks();
|
||||
initConfig().then(() => {
|
||||
if (extensionAlive()) connectBackground();
|
||||
else showContextBanner(true);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<link rel="stylesheet" href="popup.css">
|
||||
@@ -13,19 +13,21 @@
|
||||
</div>
|
||||
</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">
|
||||
<li>连接配置与 Validate 请在 DevTools 面板完成</li>
|
||||
<li>默认只捕获 XHR/Fetch</li>
|
||||
<li>Test History 可回看多次发送</li>
|
||||
<li><strong>Latest XHR</strong> 快速发送最近 API 请求</li>
|
||||
<li>Connection and Validate are in the DevTools panel</li>
|
||||
<li>Captures XHR/Fetch by default</li>
|
||||
<li>Test History stores recent sends</li>
|
||||
<li><strong>Latest XHR</strong> sends the most recent API request</li>
|
||||
</ul>
|
||||
|
||||
<script src="../lib/constants.js"></script>
|
||||
<script src="../lib/auth-session.js"></script>
|
||||
<script src="../lib/storage.js"></script>
|
||||
<script src="../lib/api.js"></script>
|
||||
<script src="popup.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -12,17 +12,37 @@ async function renderPopup() {
|
||||
try {
|
||||
const cfg = await loadConfig();
|
||||
const endpoint = baseUrlFrom(cfg);
|
||||
if (cfg.token) {
|
||||
statusEl.className = 'conn-status conn-status--ok';
|
||||
statusEl.textContent = `已连接 ${endpoint}`;
|
||||
} else {
|
||||
if (!cfg.token) {
|
||||
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 (_) {
|
||||
statusEl.className = 'conn-status conn-status--idle';
|
||||
statusEl.textContent = '无法读取配置';
|
||||
statusEl.textContent = 'Cannot read config';
|
||||
}
|
||||
}
|
||||
|
||||
renderPopup();
|
||||
renderPopup().catch(() => {});
|
||||
|
||||
Reference in New Issue
Block a user