squad-openclaw 2026.2.2703 → 2026.2.2704

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +39 -22
  2. package/dist/index.js +708 -72
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,18 +1,35 @@
1
1
  # squad-openclaw
2
2
 
3
- OpenClaw gateway plugin for [Squad](https://squad.ceo) — provides entity registry, filesystem tools, SQL queries, version management, and Tailnet internal routes for secure onboarding helpers.
3
+ OpenClaw gateway plugin for [Squad](https://squad.ceo) — provides entity registry, locked-down filesystem tools, agent setup wrappers, plugin safety controls, and Tailnet internal routes for onboarding helpers.
4
4
 
5
5
  ## Features
6
6
 
7
- | Tool / Method | Description |
7
+ | Tool / Method / Endpoint | Description |
8
8
  |---|---|
9
9
  | `entity_list`, `entity_search`, `entity_sync` | In-memory entity registry with filesystem watching (agents, skills, plugins, tools, media) |
10
10
  | `fs_read`, `fs_write`, `fs_list`, `fs_delete`, `fs_rename`, `fs_mkdir` | Remote filesystem access for browser clients (subject to security restrictions below) |
11
- | `sql_query` | Restricted SQLite query tool `sqlite3` only, scoped to `~/.openclaw/squad-ceo-data/` |
12
- | `squad.version.check`, `squad.version.update` | Plugin version management and self-update |
13
- | `tools.invoke` | RPC-based tool invocation over gateway methods — **only invokes this plugin's own tools**, each with its own security restrictions (see below) |
11
+ | `squad.agents.add` | Plugin wrapper for creating agent scaffolding (workspace seed files, sessions skeleton, and config list entry) |
12
+ | `squad.version.check` | Plugin version reporting |
13
+ | `squad.questions.validate-envelope` | HUMAN_INPUT_REQUIRED envelope validation |
14
+ | `tools.invoke`, `tools.list`, `squad.layout.get` | Core plugin RPC entrypoints for tool invocation/listing and gateway layout metadata |
14
15
  | `squad.plugin.status`, `squad.plugin.recover`, `squad.plugin.disable` | Plugin safety-state RPC control (status, recovery, manual disable) |
15
- | `/squad-internal/health`, `/squad-internal/plugin/*`, `/squad-internal/pairing/*` | Tailnet internal health, safety-state, and pairing routes with origin/Tailnet checks |
16
+ | `GET /squad-internal/health` | Tailnet internal health + pairing capability metadata |
17
+ | `GET /squad-internal/plugin/status` | Tailnet internal plugin safety-state status |
18
+ | `POST /squad-internal/plugin/recover`, `POST /squad-internal/plugin/disable` | Tailnet internal plugin safety-state controls |
19
+ | `POST /squad-internal/pairing/request`, `GET /squad-internal/pairing/status` | Tailnet internal pairing helper routes with origin/Tailnet/browser-proof checks |
20
+
21
+ ## Internal Endpoint Reference
22
+
23
+ All `/squad-internal/*` routes enforce Tailnet context and origin checks. CORS preflight (`OPTIONS`) is handled for these routes.
24
+
25
+ | Route | Method | Purpose |
26
+ |---|---|---|
27
+ | `/squad-internal/health` | `GET` | Returns plugin safety snapshot and pairing capability hints |
28
+ | `/squad-internal/plugin/status` | `GET` | Returns current plugin safety state |
29
+ | `/squad-internal/plugin/recover` | `POST` | Recovers plugin safety state back to active (unless env kill-switch is set) |
30
+ | `/squad-internal/plugin/disable` | `POST` | Manually disables plugin safety state |
31
+ | `/squad-internal/pairing/request` | `POST` | Creates pairing request via gateway-native pairing methods (`node/devices/device.pair.request`) |
32
+ | `/squad-internal/pairing/status` | `GET` | Resolves pairing status via gateway-native status methods (`node/devices/device.pair.status/get`) |
16
33
 
17
34
  ## State Directory Resolution
18
35
 
@@ -102,7 +119,7 @@ Filesystem operations are restricted to configured root directories. By default,
102
119
 
103
120
  Operators can customize via the `fs.allowedRoots` config option.
104
121
 
105
- ### Layer 4: Filesystem Write Protection + Surgical Gateway Mutations
122
+ ### Layer 4: Filesystem Write Protection
106
123
 
107
124
  These files/directories cannot be written via filesystem tools (`fs_write`, `fs_rename`, etc.), even if they fall within `allowedRoots`:
108
125
 
@@ -192,6 +209,19 @@ Pairing helper routes enforce:
192
209
 
193
210
  `/squad-internal/pairing/request` and `/squad-internal/pairing/status` are still present for compatibility, but the current SPA pairing flow no longer depends on them as primary path.
194
211
 
212
+ `POST /squad-internal/pairing/request` expects browser proof fields:
213
+
214
+ - `deviceId`
215
+ - `publicKey` (Ed25519) **or** `publicKeyJwk` (P-256)
216
+ - `signature`
217
+ - `nonce`
218
+ - `signedAt`
219
+
220
+ `GET /squad-internal/pairing/status` expects:
221
+
222
+ - `requestId` query param
223
+ - `deviceId` query param
224
+
195
225
  ### Legacy Relay-State File Handling
196
226
 
197
227
  `~/.openclaw/squad-ceo-data/relay/squad-relay.json` is still blocked by filesystem security rules to protect historical secrets from older relay transport versions.
@@ -203,23 +233,10 @@ The `tools.invoke` gateway method allows the browser to call plugin tools over W
203
233
  | Tool | What it can access | Restrictions |
204
234
  |---|---|---|
205
235
  | `fs_read`, `fs_write`, `fs_list`, `fs_delete`, `fs_rename`, `fs_mkdir` | `~/.openclaw/` only | All 4 security layers apply (blocked dirs, blocked files, redaction, allowed roots, write protection) |
206
- | `sql_query` | `~/.openclaw/squad-ceo-data/*.db` only | sqlite3 only, no shell, no command injection (see below) |
207
236
  | `entity_list`, `entity_search`, `entity_sync` | In-memory entity index | Read-only metadata (names, types, paths) |
208
- | `squad.version.check`, `squad.version.update` | npm registry | Read-only check + controlled `npm install` |
209
237
 
210
238
  It **cannot** invoke gateway core tools (`exec`, `bash`, `read`, `write`, `web_fetch`, etc.) — only the tools this plugin registers via `api.registerTool()`. Every invoked tool enforces its own security restrictions independently — `tools.invoke` is just a transport layer, not a privilege escalation.
211
239
 
212
- ## SQL Query Tool
213
-
214
- > **`sql_query` can only access the plugin's own application data** in `~/.openclaw/squad-ceo-data/`. It cannot read or modify any other files on the system — not system databases, not user documents, not gateway configuration.
215
-
216
- The `sql_query` tool provides restricted SQLite access:
217
-
218
- - **Path restriction:** Database files must be within `~/.openclaw/squad-ceo-data/` — the plugin's own data directory containing entity registries and application state. Paths outside this directory are rejected before any query is executed.
219
- - **No shell:** Uses `execFile` (not `exec`) — arguments are passed as an argv array, preventing command injection
220
- - **No arbitrary commands:** Only `sqlite3` is executed — no other binary can be invoked through this tool
221
- - **Data scope:** The databases in `squad-ceo-data/` contain only Squad application data (entity metadata, search indexes, user preferences). No credentials, tokens, or gateway configuration is stored in these databases.
222
-
223
240
  ## Build Transparency
224
241
 
225
242
  The build configuration (`tsup.config.ts`) is optimized for security auditing:
@@ -244,10 +261,10 @@ Configure in your gateway's `openclaw.json` under the plugin section:
244
261
  - **Plugin directory:** `extensions/squad-openclaw/`
245
262
  - **Security-critical files:**
246
263
  - `src/filesystem.ts` — path blocking, redaction, write protection
247
- - `src/sql.ts` — restricted SQL execution
264
+ - `src/agents.ts` — wrapper for agent setup (workspace/session skeleton + config entry update)
248
265
  - `src/http-routes.ts` — Tailnet internal routes and browser-proof validation
249
266
  - `src/shared-api.ts` — gateway method + tool registration boundaries
250
- - `src/relay-client.ts`, `src/device-keys.ts`, `src/e2e-crypto.ts` — legacy relay transport components retained in repository but not in the active Tailnet-direct setup path
267
+ - `src/plugin-safety-gateway.ts`, `src/plugin-safety-state.ts` — plugin safety-state control and persistence
251
268
 
252
269
  ## License
253
270
 
package/dist/index.js CHANGED
@@ -1233,6 +1233,150 @@ function registerQuestionMethods(api) {
1233
1233
  );
1234
1234
  }
1235
1235
 
1236
+ // src/agents.ts
1237
+ import fs7 from "fs";
1238
+ import os2 from "os";
1239
+ import path7 from "path";
1240
+ var DEFAULT_WORKSPACE_FILES = {
1241
+ "SOUL.md": "# Soul\n",
1242
+ "USER.md": "# User\n",
1243
+ "AGENTS.md": "# Agents\n",
1244
+ "IDENTITY.md": "# Identity\n",
1245
+ "BOOTSTRAP.md": "# Bootstrap\n",
1246
+ "BOOT.md": "# Boot\n",
1247
+ "HEARTBEAT.md": "# Heartbeat\n",
1248
+ "TOOLS.md": "# Tools\n"
1249
+ };
1250
+ function asRecord(value) {
1251
+ return value && typeof value === "object" ? value : {};
1252
+ }
1253
+ function normalizeAgentId(params) {
1254
+ const candidate = typeof params.agentId === "string" && params.agentId.trim() || typeof params.name === "string" && params.name.trim() || "";
1255
+ if (!candidate) throw new Error("Missing required parameter: agentId");
1256
+ if (!/^[a-z0-9_-]+$/i.test(candidate)) {
1257
+ throw new Error("Invalid agentId: only letters, numbers, _ and - are allowed");
1258
+ }
1259
+ return candidate;
1260
+ }
1261
+ function resolveWorkspacePath(input, stateDir, agentId) {
1262
+ const fallback = agentId === "main" ? path7.join(stateDir, "workspace") : path7.join(stateDir, `workspace-${agentId}`);
1263
+ if (typeof input !== "string" || !input.trim()) return fallback;
1264
+ const raw = input.trim();
1265
+ if (raw === "~/.openclaw") return stateDir;
1266
+ if (raw.startsWith("~/.openclaw/")) {
1267
+ return path7.resolve(stateDir, raw.slice("~/.openclaw/".length));
1268
+ }
1269
+ if (raw === "~") return os2.homedir();
1270
+ if (raw.startsWith("~/")) return path7.resolve(os2.homedir(), raw.slice(2));
1271
+ if (!path7.isAbsolute(raw)) return path7.resolve(stateDir, raw);
1272
+ return path7.resolve(raw);
1273
+ }
1274
+ function assertWithinStateDir(targetPath, stateDir) {
1275
+ const resolvedStateDir = path7.resolve(stateDir);
1276
+ const resolvedTarget = path7.resolve(targetPath);
1277
+ if (resolvedTarget === resolvedStateDir) return;
1278
+ if (!resolvedTarget.startsWith(resolvedStateDir + path7.sep)) {
1279
+ throw new Error("Workspace path must be inside OPENCLAW_STATE_DIR");
1280
+ }
1281
+ }
1282
+ function normalizeModel(input) {
1283
+ return typeof input === "string" && input.trim() ? input.trim() : void 0;
1284
+ }
1285
+ function normalizeIdentity(params) {
1286
+ const identity = asRecord(params.identity);
1287
+ const name = typeof identity.name === "string" && identity.name.trim() ? identity.name.trim() : typeof params.displayName === "string" && params.displayName.trim() ? params.displayName.trim() : void 0;
1288
+ const emoji = typeof identity.emoji === "string" && identity.emoji.trim() ? identity.emoji.trim() : typeof params.emoji === "string" && params.emoji.trim() ? params.emoji.trim() : void 0;
1289
+ const theme = typeof identity.theme === "string" && identity.theme.trim() ? identity.theme.trim() : typeof params.description === "string" && params.description.trim() ? params.description.trim() : typeof params.theme === "string" && params.theme.trim() ? params.theme.trim() : void 0;
1290
+ return {
1291
+ ...name ? { name } : {},
1292
+ ...emoji ? { emoji } : {},
1293
+ ...theme ? { theme } : {}
1294
+ };
1295
+ }
1296
+ function ensureWorkspaceSkeleton(workspacePath) {
1297
+ fs7.mkdirSync(workspacePath, { recursive: true });
1298
+ for (const [fileName, defaultContent] of Object.entries(DEFAULT_WORKSPACE_FILES)) {
1299
+ const filePath = path7.join(workspacePath, fileName);
1300
+ if (!fs7.existsSync(filePath)) {
1301
+ fs7.writeFileSync(filePath, defaultContent, "utf-8");
1302
+ }
1303
+ }
1304
+ }
1305
+ function ensureSessionsSkeleton(stateDir, agentId) {
1306
+ const sessionsDir = path7.join(stateDir, "agents", agentId, "sessions");
1307
+ fs7.mkdirSync(sessionsDir, { recursive: true });
1308
+ const sessionsJsonPath = path7.join(sessionsDir, "sessions.json");
1309
+ if (!fs7.existsSync(sessionsJsonPath)) {
1310
+ fs7.writeFileSync(sessionsJsonPath, "{}", "utf-8");
1311
+ }
1312
+ }
1313
+ function extractConfigList(snapshot) {
1314
+ const cfgRoot = asRecord(snapshot);
1315
+ const agentsRoot = asRecord(cfgRoot.agents);
1316
+ const list = agentsRoot.list;
1317
+ return Array.isArray(list) ? [...list] : [];
1318
+ }
1319
+ function upsertAgentListEntry(list, agentId, fields) {
1320
+ const nextList = [];
1321
+ let found = false;
1322
+ for (const entry of list) {
1323
+ const id = typeof entry?.id === "string" ? entry.id : void 0;
1324
+ if (id === agentId) {
1325
+ nextList.push({ ...entry, ...fields, id: agentId });
1326
+ found = true;
1327
+ } else {
1328
+ nextList.push(entry);
1329
+ }
1330
+ }
1331
+ if (!found) nextList.push({ id: agentId, ...fields });
1332
+ return nextList;
1333
+ }
1334
+ function registerAgentMethods(api) {
1335
+ api.registerGatewayMethod(
1336
+ "squad.agents.add",
1337
+ async (ctx) => {
1338
+ try {
1339
+ const params = ctx.params ?? {};
1340
+ const stateDir = getOpenclawStateDir();
1341
+ const configPath = process.env.OPENCLAW_CONFIG_PATH ? path7.resolve(process.env.OPENCLAW_CONFIG_PATH) : path7.join(stateDir, "openclaw.json");
1342
+ const agentId = normalizeAgentId(params);
1343
+ const workspacePath = resolveWorkspacePath(params.workspace, stateDir, agentId);
1344
+ assertWithinStateDir(workspacePath, stateDir);
1345
+ const model = normalizeModel(params.model);
1346
+ const identity = normalizeIdentity(params);
1347
+ ensureWorkspaceSkeleton(workspacePath);
1348
+ ensureSessionsSkeleton(stateDir, agentId);
1349
+ const rawConfig = fs7.readFileSync(configPath, "utf-8");
1350
+ const snapshot = JSON.parse(rawConfig);
1351
+ const cfgRoot = asRecord(snapshot);
1352
+ const agentsRoot = asRecord(cfgRoot.agents);
1353
+ const currentList = extractConfigList(snapshot);
1354
+ const nextList = upsertAgentListEntry(currentList, agentId, {
1355
+ workspace: workspacePath,
1356
+ ...model ? { model } : {},
1357
+ ...Object.keys(identity).length > 0 ? { identity } : {}
1358
+ });
1359
+ cfgRoot.agents = {
1360
+ ...agentsRoot,
1361
+ list: nextList
1362
+ };
1363
+ fs7.writeFileSync(configPath, JSON.stringify(cfgRoot, null, 2), "utf-8");
1364
+ ctx.respond(true, {
1365
+ ok: true,
1366
+ agentId,
1367
+ workspace: workspacePath
1368
+ });
1369
+ } catch (error) {
1370
+ const message = error instanceof Error ? error.message : String(error);
1371
+ ctx.respond(false, {
1372
+ error: message,
1373
+ errorMessage: message
1374
+ });
1375
+ }
1376
+ }
1377
+ );
1378
+ }
1379
+
1236
1380
  // src/safety.ts
1237
1381
  var TimeoutError = class extends Error {
1238
1382
  operation;
@@ -1268,6 +1412,262 @@ async function withTimeout(promise, timeoutMs, operation) {
1268
1412
  }
1269
1413
  }
1270
1414
 
1415
+ // src/plugin-safety-state.ts
1416
+ import crypto from "crypto";
1417
+ import fs8 from "fs";
1418
+ import path8 from "path";
1419
+ var SAFETY_DIR = path8.join(getOpenclawStateDir(), "squad-ceo-data", "safety");
1420
+ var PLUGIN_SAFETY_STATE_PATH = path8.join(SAFETY_DIR, "plugin-state.json");
1421
+ var FAILURE_THRESHOLD = readIntegerEnv("SQUAD_PLUGIN_FAILURE_THRESHOLD", 3, 1, 50);
1422
+ var FAILURE_WINDOW_MS = readTimeoutMs("SQUAD_PLUGIN_FAILURE_WINDOW_MS", 5 * 60 * 1e3, 1e3, 24 * 60 * 60 * 1e3);
1423
+ var QUARANTINE_MS = readTimeoutMs("SQUAD_PLUGIN_QUARANTINE_MS", 10 * 60 * 1e3, 1e3, 24 * 60 * 60 * 1e3);
1424
+ function readIntegerEnv(envName, fallback, min, max) {
1425
+ const raw = process.env[envName];
1426
+ if (!raw) return fallback;
1427
+ const parsed = Number(raw);
1428
+ if (!Number.isFinite(parsed)) return fallback;
1429
+ const rounded = Math.floor(parsed);
1430
+ if (rounded < min) return min;
1431
+ if (rounded > max) return max;
1432
+ return rounded;
1433
+ }
1434
+ function nowIso() {
1435
+ return (/* @__PURE__ */ new Date()).toISOString();
1436
+ }
1437
+ function normalizeIso(value) {
1438
+ if (typeof value !== "string") return null;
1439
+ const parsed = Date.parse(value);
1440
+ if (!Number.isFinite(parsed)) return null;
1441
+ return new Date(parsed).toISOString();
1442
+ }
1443
+ function normalizeString(value) {
1444
+ if (typeof value !== "string") return null;
1445
+ const trimmed = value.trim();
1446
+ return trimmed ? trimmed : null;
1447
+ }
1448
+ function normalizeState(value) {
1449
+ if (value === "ACTIVE" || value === "DISABLED_MANUAL" || value === "QUARANTINED_AUTO") {
1450
+ return value;
1451
+ }
1452
+ return "ACTIVE";
1453
+ }
1454
+ function normalizeFailureCount(value) {
1455
+ if (typeof value !== "number" || !Number.isFinite(value)) return 0;
1456
+ const rounded = Math.floor(value);
1457
+ return rounded > 0 ? rounded : 0;
1458
+ }
1459
+ function parseMs(value) {
1460
+ if (!value) return null;
1461
+ const parsed = Date.parse(value);
1462
+ if (!Number.isFinite(parsed)) return null;
1463
+ return parsed;
1464
+ }
1465
+ function defaultPersistedState() {
1466
+ return {
1467
+ version: 1,
1468
+ state: "ACTIVE",
1469
+ reasonCode: null,
1470
+ reasonMessage: null,
1471
+ remediation: null,
1472
+ triggeredAt: null,
1473
+ lastFailureAt: null,
1474
+ lastFailureCode: null,
1475
+ lastFailureMessage: null,
1476
+ failureCount: 0,
1477
+ failureWindowStartedAt: null,
1478
+ quarantineUntil: null,
1479
+ lastErrorId: null,
1480
+ updatedAt: nowIso()
1481
+ };
1482
+ }
1483
+ function sanitizePersistedState(value) {
1484
+ const state = defaultPersistedState();
1485
+ if (!value || typeof value !== "object" || Array.isArray(value)) return state;
1486
+ const record = value;
1487
+ return {
1488
+ version: 1,
1489
+ state: normalizeState(record.state),
1490
+ reasonCode: normalizeString(record.reasonCode),
1491
+ reasonMessage: normalizeString(record.reasonMessage),
1492
+ remediation: normalizeString(record.remediation),
1493
+ triggeredAt: normalizeIso(record.triggeredAt),
1494
+ lastFailureAt: normalizeIso(record.lastFailureAt),
1495
+ lastFailureCode: normalizeString(record.lastFailureCode),
1496
+ lastFailureMessage: normalizeString(record.lastFailureMessage),
1497
+ failureCount: normalizeFailureCount(record.failureCount),
1498
+ failureWindowStartedAt: normalizeIso(record.failureWindowStartedAt),
1499
+ quarantineUntil: normalizeIso(record.quarantineUntil),
1500
+ lastErrorId: normalizeString(record.lastErrorId),
1501
+ updatedAt: normalizeIso(record.updatedAt) ?? nowIso()
1502
+ };
1503
+ }
1504
+ function readPersistedState() {
1505
+ try {
1506
+ const raw = fs8.readFileSync(PLUGIN_SAFETY_STATE_PATH, "utf-8");
1507
+ return sanitizePersistedState(JSON.parse(raw));
1508
+ } catch {
1509
+ return defaultPersistedState();
1510
+ }
1511
+ }
1512
+ function writePersistedState(state) {
1513
+ try {
1514
+ fs8.mkdirSync(SAFETY_DIR, { recursive: true });
1515
+ const tempPath = `${PLUGIN_SAFETY_STATE_PATH}.${process.pid}.${Date.now()}.tmp`;
1516
+ fs8.writeFileSync(tempPath, `${JSON.stringify(state, null, 2)}
1517
+ `, "utf-8");
1518
+ fs8.renameSync(tempPath, PLUGIN_SAFETY_STATE_PATH);
1519
+ } catch (error) {
1520
+ const message = error instanceof Error ? error.message : String(error);
1521
+ console.warn(`[squad-openclaw] failed to persist plugin safety state: ${message}`);
1522
+ }
1523
+ }
1524
+ function isTruthy(value) {
1525
+ if (!value) return false;
1526
+ return /^(1|true|yes|on)$/i.test(value.trim());
1527
+ }
1528
+ function isEnvKillSwitchActive() {
1529
+ return isTruthy(process.env.SQUAD_PLUGIN_DISABLED);
1530
+ }
1531
+ function envKillSwitchReason() {
1532
+ const configured = normalizeString(process.env.SQUAD_PLUGIN_DISABLE_REASON);
1533
+ return configured ?? "Plugin disabled by SQUAD_PLUGIN_DISABLED";
1534
+ }
1535
+ function snapshotFromState(state, source, canRecover) {
1536
+ return {
1537
+ state: state.state,
1538
+ source,
1539
+ blocked: state.state !== "ACTIVE",
1540
+ canRecover,
1541
+ reasonCode: state.reasonCode,
1542
+ reasonMessage: state.reasonMessage,
1543
+ remediation: state.remediation,
1544
+ triggeredAt: state.triggeredAt,
1545
+ lastFailureAt: state.lastFailureAt,
1546
+ lastFailureCode: state.lastFailureCode,
1547
+ lastFailureMessage: state.lastFailureMessage,
1548
+ failureCount: state.failureCount,
1549
+ quarantineUntil: state.quarantineUntil,
1550
+ lastErrorId: state.lastErrorId,
1551
+ updatedAt: state.updatedAt,
1552
+ stateFilePath: PLUGIN_SAFETY_STATE_PATH
1553
+ };
1554
+ }
1555
+ function maybeReleaseExpiredQuarantine(state) {
1556
+ if (state.state !== "QUARANTINED_AUTO") return state;
1557
+ const untilMs = parseMs(state.quarantineUntil);
1558
+ if (untilMs == null || Date.now() < untilMs) return state;
1559
+ const released = {
1560
+ ...state,
1561
+ state: "ACTIVE",
1562
+ reasonCode: null,
1563
+ reasonMessage: null,
1564
+ remediation: null,
1565
+ triggeredAt: nowIso(),
1566
+ quarantineUntil: null,
1567
+ failureCount: 0,
1568
+ failureWindowStartedAt: null,
1569
+ updatedAt: nowIso()
1570
+ };
1571
+ writePersistedState(released);
1572
+ return released;
1573
+ }
1574
+ function generateErrorId() {
1575
+ try {
1576
+ return crypto.randomUUID();
1577
+ } catch {
1578
+ return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
1579
+ }
1580
+ }
1581
+ function getPluginSafetySnapshot() {
1582
+ const persisted = maybeReleaseExpiredQuarantine(readPersistedState());
1583
+ if (isEnvKillSwitchActive()) {
1584
+ const envState = {
1585
+ ...persisted,
1586
+ state: "DISABLED_MANUAL",
1587
+ reasonCode: "ENV_KILL_SWITCH",
1588
+ reasonMessage: envKillSwitchReason(),
1589
+ remediation: "Unset SQUAD_PLUGIN_DISABLED and restart the gateway process.",
1590
+ triggeredAt: persisted.triggeredAt ?? nowIso(),
1591
+ updatedAt: nowIso()
1592
+ };
1593
+ return snapshotFromState(envState, "env", false);
1594
+ }
1595
+ return snapshotFromState(persisted, "file", true);
1596
+ }
1597
+ function isPluginExecutionBlocked(snapshot) {
1598
+ return snapshot.state !== "ACTIVE";
1599
+ }
1600
+ function recordPluginFailure(failureCode, failureMessage, remediation) {
1601
+ if (isEnvKillSwitchActive()) return getPluginSafetySnapshot();
1602
+ const state = readPersistedState();
1603
+ const nowMs = Date.now();
1604
+ const now = new Date(nowMs).toISOString();
1605
+ const windowStartMs = parseMs(state.failureWindowStartedAt);
1606
+ if (windowStartMs == null || nowMs - windowStartMs > FAILURE_WINDOW_MS) {
1607
+ state.failureWindowStartedAt = now;
1608
+ state.failureCount = 1;
1609
+ } else {
1610
+ state.failureCount += 1;
1611
+ }
1612
+ state.lastFailureAt = now;
1613
+ state.lastFailureCode = failureCode;
1614
+ state.lastFailureMessage = failureMessage;
1615
+ state.lastErrorId = generateErrorId();
1616
+ if (state.state !== "DISABLED_MANUAL" && state.failureCount >= FAILURE_THRESHOLD) {
1617
+ state.state = "QUARANTINED_AUTO";
1618
+ state.reasonCode = "AUTO_QUARANTINED";
1619
+ state.reasonMessage = `Plugin auto-quarantined after ${state.failureCount} failures in ${Math.round(
1620
+ FAILURE_WINDOW_MS / 1e3
1621
+ )} seconds`;
1622
+ state.remediation = remediation ?? "Resolve the underlying plugin issue, then run squad.plugin.recover or wait for quarantine expiry.";
1623
+ state.triggeredAt = now;
1624
+ state.quarantineUntil = new Date(nowMs + QUARANTINE_MS).toISOString();
1625
+ }
1626
+ state.updatedAt = now;
1627
+ writePersistedState(state);
1628
+ return getPluginSafetySnapshot();
1629
+ }
1630
+ function setPluginManualDisabled(reasonCode = "MANUAL_KILL_SWITCH", reasonMessage = "Plugin manually disabled", remediation = "Run squad.plugin.recover after resolving plugin issues.") {
1631
+ if (isEnvKillSwitchActive()) return getPluginSafetySnapshot();
1632
+ const state = readPersistedState();
1633
+ const now = nowIso();
1634
+ state.state = "DISABLED_MANUAL";
1635
+ state.reasonCode = reasonCode;
1636
+ state.reasonMessage = reasonMessage;
1637
+ state.remediation = remediation;
1638
+ state.triggeredAt = now;
1639
+ state.quarantineUntil = null;
1640
+ state.updatedAt = now;
1641
+ writePersistedState(state);
1642
+ return getPluginSafetySnapshot();
1643
+ }
1644
+ function recoverPlugin(reasonMessage = "Plugin manually recovered") {
1645
+ if (isEnvKillSwitchActive()) {
1646
+ return {
1647
+ ok: false,
1648
+ message: "Cannot recover while SQUAD_PLUGIN_DISABLED is enabled.",
1649
+ snapshot: getPluginSafetySnapshot()
1650
+ };
1651
+ }
1652
+ const state = readPersistedState();
1653
+ const now = nowIso();
1654
+ state.state = "ACTIVE";
1655
+ state.reasonCode = null;
1656
+ state.reasonMessage = null;
1657
+ state.remediation = null;
1658
+ state.triggeredAt = now;
1659
+ state.quarantineUntil = null;
1660
+ state.failureCount = 0;
1661
+ state.failureWindowStartedAt = null;
1662
+ state.updatedAt = now;
1663
+ writePersistedState(state);
1664
+ return {
1665
+ ok: true,
1666
+ message: reasonMessage,
1667
+ snapshot: getPluginSafetySnapshot()
1668
+ };
1669
+ }
1670
+
1271
1671
  // src/shared-api.ts
1272
1672
  var CORE_TOOLS = [
1273
1673
  "exec",
@@ -1309,6 +1709,17 @@ var TOOLS_INVOKE_TIMEOUT_MS = readTimeoutMs("SQUAD_TOOLS_INVOKE_TIMEOUT_MS", 6e4
1309
1709
  function errorMessage(error) {
1310
1710
  return error instanceof Error ? error.message : String(error);
1311
1711
  }
1712
+ function pluginBlockedPayload(snapshot) {
1713
+ const code = snapshot.state === "QUARANTINED_AUTO" ? "PLUGIN_QUARANTINED" : "PLUGIN_DISABLED";
1714
+ const message = snapshot.reasonMessage ?? (snapshot.state === "QUARANTINED_AUTO" ? "Plugin is temporarily auto-quarantined" : "Plugin is currently disabled");
1715
+ return {
1716
+ code,
1717
+ error: message,
1718
+ errorCode: code,
1719
+ errorMessage: message,
1720
+ plugin: snapshot
1721
+ };
1722
+ }
1312
1723
  function registerSquadSharedApi(api, onFsChange) {
1313
1724
  const toolExecutors = /* @__PURE__ */ new Map();
1314
1725
  const registerToolFn = typeof api?.registerTool === "function" ? api.registerTool.bind(api) : null;
@@ -1333,6 +1744,7 @@ function registerSquadSharedApi(api, onFsChange) {
1333
1744
  registerStep("filesystem tools", () => registerFilesystemTools(api));
1334
1745
  registerStep("version methods", () => registerVersionMethods(api));
1335
1746
  registerStep("question methods", () => registerQuestionMethods(api));
1747
+ registerStep("agent methods", () => registerAgentMethods(api));
1336
1748
  const invokeTool = async (tool, args) => {
1337
1749
  const executeFn = toolExecutors.get(tool);
1338
1750
  if (!executeFn) {
@@ -1357,6 +1769,11 @@ function registerSquadSharedApi(api, onFsChange) {
1357
1769
  safeRegisterGatewayMethod(
1358
1770
  "tools.invoke",
1359
1771
  async ({ params, respond }) => {
1772
+ const safetySnapshot = getPluginSafetySnapshot();
1773
+ if (isPluginExecutionBlocked(safetySnapshot)) {
1774
+ respond(false, pluginBlockedPayload(safetySnapshot));
1775
+ return;
1776
+ }
1360
1777
  const tool = params?.tool;
1361
1778
  const args = params?.args ?? {};
1362
1779
  if (!tool) {
@@ -1372,7 +1789,18 @@ function registerSquadSharedApi(api, onFsChange) {
1372
1789
  respond(true, result);
1373
1790
  } catch (err2) {
1374
1791
  if (err2 instanceof TimeoutError) {
1792
+ const snapshot = recordPluginFailure(
1793
+ "TOOLS_INVOKE_TIMEOUT",
1794
+ `Tool '${tool}' timed out after ${TOOLS_INVOKE_TIMEOUT_MS}ms`,
1795
+ "Investigate long-running plugin operations and run squad.plugin.recover."
1796
+ );
1797
+ if (isPluginExecutionBlocked(snapshot)) {
1798
+ respond(false, pluginBlockedPayload(snapshot));
1799
+ return;
1800
+ }
1375
1801
  respond(false, {
1802
+ code: "PLUGIN_TIMEOUT",
1803
+ error: `Tool '${tool}' timed out after ${TOOLS_INVOKE_TIMEOUT_MS}ms`,
1376
1804
  errorCode: "PLUGIN_TIMEOUT",
1377
1805
  errorMessage: `Tool '${tool}' timed out after ${TOOLS_INVOKE_TIMEOUT_MS}ms`
1378
1806
  });
@@ -1385,12 +1813,22 @@ function registerSquadSharedApi(api, onFsChange) {
1385
1813
  safeRegisterGatewayMethod(
1386
1814
  "tools.list",
1387
1815
  async ({ respond }) => {
1816
+ const safetySnapshot = getPluginSafetySnapshot();
1817
+ if (isPluginExecutionBlocked(safetySnapshot)) {
1818
+ respond(false, pluginBlockedPayload(safetySnapshot));
1819
+ return;
1820
+ }
1388
1821
  respond(true, { tools: listTools() });
1389
1822
  }
1390
1823
  );
1391
1824
  safeRegisterGatewayMethod(
1392
1825
  "squad.layout.get",
1393
1826
  async ({ respond }) => {
1827
+ const safetySnapshot = getPluginSafetySnapshot();
1828
+ if (isPluginExecutionBlocked(safetySnapshot)) {
1829
+ respond(false, pluginBlockedPayload(safetySnapshot));
1830
+ return;
1831
+ }
1394
1832
  try {
1395
1833
  const layout = resolveGatewayLayout();
1396
1834
  respond(true, layout);
@@ -1408,13 +1846,13 @@ function registerSquadSharedApi(api, onFsChange) {
1408
1846
  }
1409
1847
 
1410
1848
  // src/migrations/runner.ts
1411
- import fs9 from "fs";
1412
- import path9 from "path";
1849
+ import fs11 from "fs";
1850
+ import path11 from "path";
1413
1851
 
1414
1852
  // src/migrations/001-enable-main-subagent-access.ts
1415
- import fs7 from "fs";
1416
- import path7 from "path";
1417
- function asRecord(value) {
1853
+ import fs9 from "fs";
1854
+ import path9 from "path";
1855
+ function asRecord2(value) {
1418
1856
  if (!value || typeof value !== "object" || Array.isArray(value)) return null;
1419
1857
  return value;
1420
1858
  }
@@ -1429,24 +1867,24 @@ function mergeStringArrayWithWildcard(value) {
1429
1867
  return Array.from(next);
1430
1868
  }
1431
1869
  function patchConfigOnDisk() {
1432
- const configPath = path7.join(getOpenclawStateDir(), "openclaw.json");
1433
- const raw = fs7.readFileSync(configPath, "utf-8");
1870
+ const configPath = path9.join(getOpenclawStateDir(), "openclaw.json");
1871
+ const raw = fs9.readFileSync(configPath, "utf-8");
1434
1872
  const parsed = JSON.parse(raw);
1435
- const agents = asRecord(parsed.agents) ?? {};
1436
- const defaults = asRecord(agents.defaults) ?? {};
1437
- const subagentsDefaults = asRecord(defaults.subagents) ?? {};
1873
+ const agents = asRecord2(parsed.agents) ?? {};
1874
+ const defaults = asRecord2(agents.defaults) ?? {};
1875
+ const subagentsDefaults = asRecord2(defaults.subagents) ?? {};
1438
1876
  defaults.maxConcurrent = 4;
1439
1877
  defaults.subagents = {
1440
1878
  ...subagentsDefaults,
1441
1879
  maxConcurrent: 8
1442
1880
  };
1443
1881
  const listRaw = Array.isArray(agents.list) ? agents.list : [];
1444
- const list = listRaw.map((entry) => asRecord(entry)).filter((entry) => Boolean(entry));
1882
+ const list = listRaw.map((entry) => asRecord2(entry)).filter((entry) => Boolean(entry));
1445
1883
  const mainIndex = list.findIndex((entry) => entry.id === "main");
1446
1884
  const existingMain = mainIndex >= 0 ? list[mainIndex] : {};
1447
- const existingIdentity = asRecord(existingMain.identity) ?? {};
1448
- const existingTools = asRecord(existingMain.tools) ?? {};
1449
- const existingSubagents = asRecord(existingMain.subagents) ?? {};
1885
+ const existingIdentity = asRecord2(existingMain.identity) ?? {};
1886
+ const existingTools = asRecord2(existingMain.tools) ?? {};
1887
+ const existingSubagents = asRecord2(existingMain.subagents) ?? {};
1450
1888
  const nextMain = {
1451
1889
  ...existingMain,
1452
1890
  id: "main",
@@ -1470,7 +1908,7 @@ function patchConfigOnDisk() {
1470
1908
  defaults,
1471
1909
  list
1472
1910
  };
1473
- fs7.writeFileSync(configPath, `${JSON.stringify(parsed, null, 2)}
1911
+ fs9.writeFileSync(configPath, `${JSON.stringify(parsed, null, 2)}
1474
1912
  `, "utf-8");
1475
1913
  }
1476
1914
  var migration = {
@@ -1545,13 +1983,13 @@ var migration = {
1545
1983
  var enable_main_subagent_access_default = migration;
1546
1984
 
1547
1985
  // src/auth-profiles.ts
1548
- import fs8 from "fs";
1549
- import path8 from "path";
1986
+ import fs10 from "fs";
1987
+ import path10 from "path";
1550
1988
  function getMainAuthProfilesPath(stateDir) {
1551
- return path8.join(stateDir, "agents", "main", "agent", "auth-profiles.json");
1989
+ return path10.join(stateDir, "agents", "main", "agent", "auth-profiles.json");
1552
1990
  }
1553
1991
  function getAgentAuthProfilesPath(stateDir, agentId) {
1554
- return path8.join(stateDir, "agents", agentId, "agent", "auth-profiles.json");
1992
+ return path10.join(stateDir, "agents", agentId, "agent", "auth-profiles.json");
1555
1993
  }
1556
1994
  function ensureAgentAuthProfiles(agentId) {
1557
1995
  const normalizedAgentId = agentId.trim();
@@ -1559,17 +1997,17 @@ function ensureAgentAuthProfiles(agentId) {
1559
1997
  const stateDir = getOpenclawStateDir();
1560
1998
  const sourcePath = getMainAuthProfilesPath(stateDir);
1561
1999
  const targetPath = getAgentAuthProfilesPath(stateDir, normalizedAgentId);
1562
- if (!fs8.existsSync(sourcePath) || fs8.existsSync(targetPath)) return false;
1563
- fs8.mkdirSync(path8.dirname(targetPath), { recursive: true });
1564
- fs8.copyFileSync(sourcePath, targetPath);
2000
+ if (!fs10.existsSync(sourcePath) || fs10.existsSync(targetPath)) return false;
2001
+ fs10.mkdirSync(path10.dirname(targetPath), { recursive: true });
2002
+ fs10.copyFileSync(sourcePath, targetPath);
1565
2003
  return true;
1566
2004
  }
1567
2005
  function backfillAgentAuthProfiles() {
1568
2006
  const stateDir = getOpenclawStateDir();
1569
- const agentsDir = path8.join(stateDir, "agents");
1570
- if (!fs8.existsSync(agentsDir)) return [];
2007
+ const agentsDir = path10.join(stateDir, "agents");
2008
+ if (!fs10.existsSync(agentsDir)) return [];
1571
2009
  const copied = [];
1572
- for (const entry of fs8.readdirSync(agentsDir, { withFileTypes: true })) {
2010
+ for (const entry of fs10.readdirSync(agentsDir, { withFileTypes: true })) {
1573
2011
  if (!entry.isDirectory()) continue;
1574
2012
  const agentId = entry.name;
1575
2013
  if (ensureAgentAuthProfiles(agentId)) {
@@ -1600,8 +2038,8 @@ var STARTUP_MIGRATIONS = [
1600
2038
  ];
1601
2039
 
1602
2040
  // src/migrations/runner.ts
1603
- var MIGRATIONS_DIR = path9.join(getOpenclawStateDir(), "squad-ceo-data");
1604
- var MIGRATIONS_PATH = path9.join(MIGRATIONS_DIR, "migrations.json");
2041
+ var MIGRATIONS_DIR = path11.join(getOpenclawStateDir(), "squad-ceo-data");
2042
+ var MIGRATIONS_PATH = path11.join(MIGRATIONS_DIR, "migrations.json");
1605
2043
  var STARTUP_MIGRATION_TIMEOUT_MS = readTimeoutMs("SQUAD_STARTUP_MIGRATION_TIMEOUT_MS", 2e4);
1606
2044
  var STARTUP_GATEWAY_CALL_TIMEOUT_MS = readTimeoutMs("SQUAD_STARTUP_GATEWAY_CALL_TIMEOUT_MS", 5e3);
1607
2045
  function defaultState() {
@@ -1612,7 +2050,7 @@ function defaultState() {
1612
2050
  }
1613
2051
  function readState() {
1614
2052
  try {
1615
- const raw = fs9.readFileSync(MIGRATIONS_PATH, "utf-8");
2053
+ const raw = fs11.readFileSync(MIGRATIONS_PATH, "utf-8");
1616
2054
  const parsed = JSON.parse(raw);
1617
2055
  if (!Array.isArray(parsed.completed)) return defaultState();
1618
2056
  return {
@@ -1624,8 +2062,8 @@ function readState() {
1624
2062
  }
1625
2063
  }
1626
2064
  function writeState(state) {
1627
- fs9.mkdirSync(MIGRATIONS_DIR, { recursive: true });
1628
- fs9.writeFileSync(MIGRATIONS_PATH, JSON.stringify(state, null, 2), "utf-8");
2065
+ fs11.mkdirSync(MIGRATIONS_DIR, { recursive: true });
2066
+ fs11.writeFileSync(MIGRATIONS_PATH, JSON.stringify(state, null, 2), "utf-8");
1629
2067
  }
1630
2068
  function makeGatewayCaller(api) {
1631
2069
  return async (method, params = {}) => {
@@ -1691,10 +2129,10 @@ async function runStartupMigrations(api) {
1691
2129
  }
1692
2130
 
1693
2131
  // src/http-routes.ts
1694
- import crypto from "crypto";
2132
+ import crypto2 from "crypto";
1695
2133
 
1696
2134
  // src/gateway-invoke.ts
1697
- function asRecord2(value) {
2135
+ function asRecord3(value) {
1698
2136
  return value && typeof value === "object" ? value : null;
1699
2137
  }
1700
2138
  function isInvoker(fn) {
@@ -1706,8 +2144,8 @@ function isUnknownGatewayMethodError(message) {
1706
2144
  );
1707
2145
  }
1708
2146
  async function callGatewayAny(ctx, api, method, params) {
1709
- const ctxGateway = asRecord2(ctx.gateway);
1710
- const apiGateway = asRecord2(api?.gateway);
2147
+ const ctxGateway = asRecord3(ctx.gateway);
2148
+ const apiGateway = asRecord3(api?.gateway);
1711
2149
  const candidates = [
1712
2150
  ctx.request,
1713
2151
  ctx.callGatewayMethod,
@@ -1768,7 +2206,7 @@ var PAIRING_GATEWAY_CALL_TIMEOUT_MS = readTimeoutMs("SQUAD_PAIRING_GATEWAY_CALL_
1768
2206
  function errorMessage2(error) {
1769
2207
  return error instanceof Error ? error.message : String(error);
1770
2208
  }
1771
- function asRecord3(value) {
2209
+ function asRecord4(value) {
1772
2210
  if (!value || typeof value !== "object" || Array.isArray(value)) return null;
1773
2211
  return value;
1774
2212
  }
@@ -1834,6 +2272,11 @@ function ensureOriginAllowed(request) {
1834
2272
  if (!origin) return null;
1835
2273
  return resolveAllowedOrigin(origin);
1836
2274
  }
2275
+ function isOriginAllowedIfPresent(request) {
2276
+ const origin = request.headers.get("origin");
2277
+ if (!origin) return true;
2278
+ return resolveAllowedOrigin(origin) !== null;
2279
+ }
1837
2280
  function firstHeaderToken(value) {
1838
2281
  if (!value) return null;
1839
2282
  const first = value.split(",")[0]?.trim();
@@ -1923,7 +2366,7 @@ function encodeUtf8(value) {
1923
2366
  function normalizeDevicePublicKeyBase64Url(publicKey) {
1924
2367
  try {
1925
2368
  if (publicKey.includes("BEGIN")) {
1926
- const spki = crypto.createPublicKey(publicKey).export({
2369
+ const spki = crypto2.createPublicKey(publicKey).export({
1927
2370
  type: "spki",
1928
2371
  format: "der"
1929
2372
  });
@@ -1943,7 +2386,7 @@ function deriveDeviceIdFromPublicKey(publicKey) {
1943
2386
  if (!normalized) return null;
1944
2387
  const raw = decodeBase64Url(normalized);
1945
2388
  if (raw.length !== 32) return null;
1946
- return crypto.createHash("sha256").update(raw).digest("hex");
2389
+ return crypto2.createHash("sha256").update(raw).digest("hex");
1947
2390
  } catch {
1948
2391
  return null;
1949
2392
  }
@@ -1952,7 +2395,7 @@ function verifyEd25519Signature(publicKey, payload, signatureBase64Url) {
1952
2395
  try {
1953
2396
  const normalized = normalizeDevicePublicKeyBase64Url(publicKey);
1954
2397
  if (!normalized) return false;
1955
- const key = crypto.createPublicKey({
2398
+ const key = crypto2.createPublicKey({
1956
2399
  key: Buffer.concat([ED25519_SPKI_PREFIX, decodeBase64Url(normalized)]),
1957
2400
  type: "spki",
1958
2401
  format: "der"
@@ -1964,13 +2407,13 @@ function verifyEd25519Signature(publicKey, payload, signatureBase64Url) {
1964
2407
  return Buffer.from(signatureBase64Url, "base64");
1965
2408
  }
1966
2409
  })();
1967
- return crypto.verify(null, Buffer.from(payload, "utf8"), key, signature);
2410
+ return crypto2.verify(null, Buffer.from(payload, "utf8"), key, signature);
1968
2411
  } catch {
1969
2412
  return false;
1970
2413
  }
1971
2414
  }
1972
2415
  function canonicalizeP256Jwk(value) {
1973
- const record = asRecord3(value);
2416
+ const record = asRecord4(value);
1974
2417
  const kty = pickString(record?.kty);
1975
2418
  const crv = pickString(record?.crv);
1976
2419
  const x = pickString(record?.x);
@@ -1982,7 +2425,7 @@ function canonicalizeP256Jwk(value) {
1982
2425
  }
1983
2426
  function computeDeviceIdFromJwk(jwk) {
1984
2427
  const canonical = JSON.stringify(jwk);
1985
- return crypto.createHash("sha256").update(canonical).digest("hex");
2428
+ return crypto2.createHash("sha256").update(canonical).digest("hex");
1986
2429
  }
1987
2430
  function buildProofPayload(action, deviceId, nonce, signedAt, origin) {
1988
2431
  return `squad.${action}|${deviceId}|${nonce}|${signedAt}|${origin}`;
@@ -2063,14 +2506,14 @@ async function verifyBrowserProof(payload, origin, action, usedProofNonces) {
2063
2506
  };
2064
2507
  }
2065
2508
  try {
2066
- const key = await crypto.webcrypto.subtle.importKey(
2509
+ const key = await crypto2.webcrypto.subtle.importKey(
2067
2510
  "jwk",
2068
2511
  jwk,
2069
2512
  { name: "ECDSA", namedCurve: "P-256" },
2070
2513
  false,
2071
2514
  ["verify"]
2072
2515
  );
2073
- verified = await crypto.webcrypto.subtle.verify(
2516
+ verified = await crypto2.webcrypto.subtle.verify(
2074
2517
  { name: "ECDSA", hash: "SHA-256" },
2075
2518
  key,
2076
2519
  decodeBase64Url(signature),
@@ -2101,18 +2544,18 @@ function normalizePairingStatus(value) {
2101
2544
  return "unknown";
2102
2545
  }
2103
2546
  function extractRequestId(result) {
2104
- const obj = asRecord3(result);
2547
+ const obj = asRecord4(result);
2105
2548
  if (!obj) return null;
2106
- const nestedRequest = asRecord3(obj.request);
2549
+ const nestedRequest = asRecord4(obj.request);
2107
2550
  return pickString(obj.requestId) ?? pickString(obj.id) ?? pickString(nestedRequest?.requestId) ?? pickString(nestedRequest?.id);
2108
2551
  }
2109
2552
  function extractExpiresAt(result) {
2110
- const obj = asRecord3(result);
2553
+ const obj = asRecord4(result);
2111
2554
  const candidates = [
2112
2555
  obj?.expiresAt,
2113
2556
  obj?.expiresAtMs,
2114
- asRecord3(obj?.request)?.expiresAt,
2115
- asRecord3(obj?.request)?.expiresAtMs
2557
+ asRecord4(obj?.request)?.expiresAt,
2558
+ asRecord4(obj?.request)?.expiresAtMs
2116
2559
  ];
2117
2560
  for (const candidate of candidates) {
2118
2561
  if (typeof candidate === "number" && Number.isFinite(candidate) && candidate > Date.now()) {
@@ -2132,12 +2575,12 @@ function extractExpiresAt(result) {
2132
2575
  return Date.now() + DEFAULT_PAIRING_TTL_MS;
2133
2576
  }
2134
2577
  function extractPairingStatus(result) {
2135
- const obj = asRecord3(result);
2578
+ const obj = asRecord4(result);
2136
2579
  if (!obj) return "unknown";
2137
2580
  const candidates = [
2138
2581
  obj.status,
2139
- asRecord3(obj.request)?.status,
2140
- asRecord3(obj.pairing)?.status
2582
+ asRecord4(obj.request)?.status,
2583
+ asRecord4(obj.pairing)?.status
2141
2584
  ];
2142
2585
  for (const candidate of candidates) {
2143
2586
  const parsed = normalizePairingStatus(candidate);
@@ -2158,6 +2601,12 @@ function isRateLimited(bucket, key, limit, windowMs) {
2158
2601
  existing.count += 1;
2159
2602
  return false;
2160
2603
  }
2604
+ function pluginBlockedCode(snapshot) {
2605
+ return snapshot.state === "QUARANTINED_AUTO" ? "PLUGIN_QUARANTINED" : "PLUGIN_DISABLED";
2606
+ }
2607
+ function pluginBlockedMessage(snapshot) {
2608
+ return snapshot.reasonMessage ?? (snapshot.state === "QUARANTINED_AUTO" ? "Plugin is temporarily auto-quarantined" : "Plugin is currently disabled");
2609
+ }
2161
2610
  function registerTailnetInternalRoutes(api) {
2162
2611
  const pendingPairings = /* @__PURE__ */ new Map();
2163
2612
  const proofNonces = /* @__PURE__ */ new Map();
@@ -2228,9 +2677,10 @@ function registerTailnetInternalRoutes(api) {
2228
2677
  };
2229
2678
  const handleRequest = async (request) => {
2230
2679
  const url = getRequestUrl(request);
2231
- const path10 = url.pathname;
2680
+ const path12 = url.pathname;
2232
2681
  cleanupCaches();
2233
- if (request.method === "OPTIONS" && path10.startsWith("/squad-internal/")) {
2682
+ let pluginState = getPluginSafetySnapshot();
2683
+ if (request.method === "OPTIONS" && path12.startsWith("/squad-internal/")) {
2234
2684
  const origin = ensureOriginAllowed(request);
2235
2685
  if (!origin) {
2236
2686
  return new Response(null, { status: 403 });
@@ -2248,7 +2698,7 @@ function registerTailnetInternalRoutes(api) {
2248
2698
  })
2249
2699
  );
2250
2700
  }
2251
- if (request.method === "GET" && path10 === "/squad-internal/health") {
2701
+ if (request.method === "GET" && path12 === "/squad-internal/health") {
2252
2702
  if (!isTailnetContext(request)) {
2253
2703
  return jsonError(request, "TAILNET_REQUIRED", "Tailnet context required", 403);
2254
2704
  }
@@ -2257,6 +2707,7 @@ function registerTailnetInternalRoutes(api) {
2257
2707
  json({
2258
2708
  ok: true,
2259
2709
  mode: "tailnet-direct",
2710
+ plugin: pluginState,
2260
2711
  pairing: {
2261
2712
  requestSupported: preferredPairingRequestMethod ?? PAIRING_REQUEST_METHODS[0],
2262
2713
  statusSupported: preferredPairingStatusMethod ?? "auto"
@@ -2264,7 +2715,91 @@ function registerTailnetInternalRoutes(api) {
2264
2715
  })
2265
2716
  );
2266
2717
  }
2267
- if (request.method === "POST" && path10 === "/squad-internal/pairing/request") {
2718
+ if (request.method === "GET" && path12 === "/squad-internal/plugin/status") {
2719
+ if (!isTailnetContext(request)) {
2720
+ return jsonError(request, "TAILNET_REQUIRED", "Tailnet context required", 403);
2721
+ }
2722
+ if (!isOriginAllowedIfPresent(request)) {
2723
+ return jsonError(request, "ORIGIN_NOT_ALLOWED", "Origin is not allowed", 403);
2724
+ }
2725
+ return withCors(
2726
+ request,
2727
+ json({
2728
+ ok: true,
2729
+ plugin: pluginState
2730
+ })
2731
+ );
2732
+ }
2733
+ if (request.method === "POST" && path12 === "/squad-internal/plugin/recover") {
2734
+ if (!isTailnetContext(request)) {
2735
+ return jsonError(request, "TAILNET_REQUIRED", "Tailnet context required", 403);
2736
+ }
2737
+ if (!isOriginAllowedIfPresent(request)) {
2738
+ return jsonError(request, "ORIGIN_NOT_ALLOWED", "Origin is not allowed", 403);
2739
+ }
2740
+ let note;
2741
+ try {
2742
+ const body = await request.json();
2743
+ if (typeof body?.note === "string" && body.note.trim()) {
2744
+ note = body.note.trim();
2745
+ }
2746
+ } catch {
2747
+ }
2748
+ const result = recoverPlugin(note ?? "Plugin recovered via internal route");
2749
+ if (!result.ok) {
2750
+ return withCors(
2751
+ request,
2752
+ json(
2753
+ {
2754
+ ok: false,
2755
+ code: "PLUGIN_KILL_SWITCH_ENV",
2756
+ error: result.message,
2757
+ plugin: result.snapshot
2758
+ },
2759
+ 409
2760
+ )
2761
+ );
2762
+ }
2763
+ pluginState = result.snapshot;
2764
+ return withCors(request, json({ ok: true, plugin: pluginState, message: result.message }));
2765
+ }
2766
+ if (request.method === "POST" && path12 === "/squad-internal/plugin/disable") {
2767
+ if (!isTailnetContext(request)) {
2768
+ return jsonError(request, "TAILNET_REQUIRED", "Tailnet context required", 403);
2769
+ }
2770
+ if (!isOriginAllowedIfPresent(request)) {
2771
+ return jsonError(request, "ORIGIN_NOT_ALLOWED", "Origin is not allowed", 403);
2772
+ }
2773
+ let reasonCode = "MANUAL_KILL_SWITCH";
2774
+ let reasonMessage = "Plugin manually disabled via internal route";
2775
+ let remediation = "Run /squad-internal/plugin/recover after fixing plugin issues.";
2776
+ try {
2777
+ const body = await request.json();
2778
+ if (typeof body?.reasonCode === "string" && body.reasonCode.trim()) {
2779
+ reasonCode = body.reasonCode.trim();
2780
+ }
2781
+ if (typeof body?.reasonMessage === "string" && body.reasonMessage.trim()) {
2782
+ reasonMessage = body.reasonMessage.trim();
2783
+ }
2784
+ if (typeof body?.remediation === "string" && body.remediation.trim()) {
2785
+ remediation = body.remediation.trim();
2786
+ }
2787
+ } catch {
2788
+ }
2789
+ pluginState = setPluginManualDisabled(reasonCode, reasonMessage, remediation);
2790
+ return withCors(request, json({ ok: true, plugin: pluginState }));
2791
+ }
2792
+ if (path12.startsWith("/squad-internal/") && isPluginExecutionBlocked(pluginState)) {
2793
+ const code = pluginBlockedCode(pluginState);
2794
+ return jsonError(
2795
+ request,
2796
+ code,
2797
+ pluginBlockedMessage(pluginState),
2798
+ 503,
2799
+ { plugin: pluginState }
2800
+ );
2801
+ }
2802
+ if (request.method === "POST" && path12 === "/squad-internal/pairing/request") {
2268
2803
  const origin = ensureOriginAllowed(request);
2269
2804
  if (!origin) {
2270
2805
  return jsonError(request, "ORIGIN_NOT_ALLOWED", "Origin is not allowed", 403);
@@ -2316,7 +2851,7 @@ function registerTailnetInternalRoutes(api) {
2316
2851
  );
2317
2852
  }
2318
2853
  }
2319
- if (request.method === "GET" && path10 === "/squad-internal/pairing/status") {
2854
+ if (request.method === "GET" && path12 === "/squad-internal/pairing/status") {
2320
2855
  const origin = ensureOriginAllowed(request);
2321
2856
  if (!origin) {
2322
2857
  return jsonError(request, "ORIGIN_NOT_ALLOWED", "Origin is not allowed", 403);
@@ -2365,20 +2900,32 @@ function registerTailnetInternalRoutes(api) {
2365
2900
  );
2366
2901
  } catch (error) {
2367
2902
  if (error instanceof TimeoutError) {
2903
+ const snapshot2 = recordPluginFailure(
2904
+ "INTERNAL_ROUTE_TIMEOUT",
2905
+ `${error.operation} timed out after ${error.timeoutMs}ms`,
2906
+ "Investigate internal route hangs and run squad.plugin.recover after remediation."
2907
+ );
2368
2908
  console.warn(`[squad-openclaw] ${error.operation}`);
2369
2909
  return jsonError(
2370
2910
  request,
2371
- "ROUTE_TIMEOUT",
2911
+ snapshot2.state === "QUARANTINED_AUTO" ? "PLUGIN_QUARANTINED" : "ROUTE_TIMEOUT",
2372
2912
  `Internal route timed out after ${error.timeoutMs}ms`,
2373
- 504
2913
+ snapshot2.state === "QUARANTINED_AUTO" ? 503 : 504,
2914
+ snapshot2.state === "QUARANTINED_AUTO" ? { plugin: snapshot2 } : {}
2374
2915
  );
2375
2916
  }
2917
+ const snapshot = recordPluginFailure(
2918
+ "INTERNAL_ROUTE_ERROR",
2919
+ errorMessage2(error),
2920
+ "Inspect internal route failures and run squad.plugin.recover after remediation."
2921
+ );
2376
2922
  console.warn(`[squad-openclaw] internal route failure: ${errorMessage2(error)}`);
2377
2923
  return jsonError(
2378
2924
  request,
2379
- "INTERNAL_ROUTE_ERROR",
2925
+ snapshot.state === "QUARANTINED_AUTO" ? "PLUGIN_QUARANTINED" : "INTERNAL_ROUTE_ERROR",
2380
2926
  "Internal route failed unexpectedly",
2381
- 500
2927
+ snapshot.state === "QUARANTINED_AUTO" ? 503 : 500,
2928
+ snapshot.state === "QUARANTINED_AUTO" ? { plugin: snapshot } : {}
2382
2929
  );
2383
2930
  }
2384
2931
  };
@@ -2386,6 +2933,9 @@ function registerTailnetInternalRoutes(api) {
2386
2933
  api.registerHttpHandler(handle);
2387
2934
  } else if (typeof api.registerHttpRoute === "function") {
2388
2935
  api.registerHttpRoute("/squad-internal/health", handle);
2936
+ api.registerHttpRoute("/squad-internal/plugin/status", handle);
2937
+ api.registerHttpRoute("/squad-internal/plugin/recover", handle);
2938
+ api.registerHttpRoute("/squad-internal/plugin/disable", handle);
2389
2939
  api.registerHttpRoute("/squad-internal/pairing/request", handle);
2390
2940
  api.registerHttpRoute("/squad-internal/pairing/status", handle);
2391
2941
  api.registerHttpRoute("/squad-internal/*", handle);
@@ -2396,36 +2946,122 @@ function registerTailnetInternalRoutes(api) {
2396
2946
  }
2397
2947
  }
2398
2948
 
2949
+ // src/plugin-safety-gateway.ts
2950
+ function errorMessage3(error) {
2951
+ return error instanceof Error ? error.message : String(error);
2952
+ }
2953
+ function registerPluginSafetyGatewayMethods(api) {
2954
+ const registerGatewayMethod = typeof api?.registerGatewayMethod === "function" ? api.registerGatewayMethod.bind(api) : null;
2955
+ if (!registerGatewayMethod) {
2956
+ console.warn("[squad-openclaw] registerGatewayMethod unavailable; plugin safety methods disabled");
2957
+ return;
2958
+ }
2959
+ const safeRegister = (method, handler) => {
2960
+ try {
2961
+ registerGatewayMethod(method, handler);
2962
+ } catch (error) {
2963
+ console.warn(`[squad-openclaw] failed to register ${method}: ${errorMessage3(error)}`);
2964
+ }
2965
+ };
2966
+ safeRegister("squad.plugin.status", async ({ respond }) => {
2967
+ respond(true, { plugin: getPluginSafetySnapshot() });
2968
+ });
2969
+ safeRegister("squad.plugin.recover", async ({ params, respond }) => {
2970
+ const note = typeof params?.note === "string" ? params.note.trim() : "";
2971
+ const result = recoverPlugin(note || "Plugin recovered via gateway RPC");
2972
+ if (!result.ok) {
2973
+ respond(false, {
2974
+ code: "PLUGIN_KILL_SWITCH_ENV",
2975
+ error: result.message,
2976
+ plugin: result.snapshot
2977
+ });
2978
+ return;
2979
+ }
2980
+ respond(true, { plugin: result.snapshot, message: result.message });
2981
+ });
2982
+ safeRegister("squad.plugin.disable", async ({ params, respond }) => {
2983
+ const reasonCode = typeof params?.reasonCode === "string" && params.reasonCode.trim() ? params.reasonCode.trim() : "MANUAL_KILL_SWITCH";
2984
+ const reasonMessage = typeof params?.reasonMessage === "string" && params.reasonMessage.trim() ? params.reasonMessage.trim() : "Plugin manually disabled via gateway RPC";
2985
+ const remediation = typeof params?.remediation === "string" && params.remediation.trim() ? params.remediation.trim() : "Run squad.plugin.recover after resolving plugin issues.";
2986
+ const snapshot = setPluginManualDisabled(reasonCode, reasonMessage, remediation);
2987
+ respond(true, { plugin: snapshot });
2988
+ });
2989
+ }
2990
+
2399
2991
  // src/index.ts
2400
2992
  var STARTUP_MIGRATIONS_TIMEOUT_MS = readTimeoutMs("SQUAD_STARTUP_MIGRATIONS_TIMEOUT_MS", 3e4);
2401
- function errorMessage3(error) {
2993
+ function errorMessage4(error) {
2402
2994
  return error instanceof Error ? error.message : String(error);
2403
2995
  }
2404
2996
  function squadAppPlugin(api) {
2997
+ registerPluginSafetyGatewayMethods(api);
2998
+ let safetySnapshot = getPluginSafetySnapshot();
2999
+ if (safetySnapshot.blocked) {
3000
+ console.warn(
3001
+ `[squad-openclaw] plugin boot starts in ${safetySnapshot.state} mode (${safetySnapshot.reasonCode ?? "NO_REASON"})`
3002
+ );
3003
+ }
2405
3004
  let sharedApi = null;
3005
+ try {
3006
+ registerTailnetInternalRoutes(api);
3007
+ } catch (error) {
3008
+ safetySnapshot = recordPluginFailure(
3009
+ "STARTUP_INTERNAL_ROUTES_FAILED",
3010
+ errorMessage4(error),
3011
+ "Fix route registration errors and run squad.plugin.recover."
3012
+ );
3013
+ console.warn(`[squad-openclaw] internal route registration failed: ${errorMessage4(error)}`);
3014
+ }
3015
+ safetySnapshot = getPluginSafetySnapshot();
3016
+ if (isPluginExecutionBlocked(safetySnapshot)) {
3017
+ console.warn(
3018
+ `[squad-openclaw] plugin features disabled (${safetySnapshot.state}). ${safetySnapshot.reasonMessage ?? "No reason provided."}`
3019
+ );
3020
+ return;
3021
+ }
2406
3022
  try {
2407
3023
  sharedApi = registerSquadSharedApi(api);
2408
3024
  } catch (error) {
2409
- console.warn(`[squad-openclaw] shared API registration failed: ${errorMessage3(error)}`);
3025
+ safetySnapshot = recordPluginFailure(
3026
+ "STARTUP_SHARED_API_FAILED",
3027
+ errorMessage4(error),
3028
+ "Fix shared API registration errors and run squad.plugin.recover."
3029
+ );
3030
+ console.warn(`[squad-openclaw] shared API registration failed: ${errorMessage4(error)}`);
2410
3031
  }
2411
- if (sharedApi) {
2412
- try {
2413
- sharedApi.registerCoreGatewayMethods();
2414
- } catch (error) {
2415
- console.warn(`[squad-openclaw] core gateway method registration failed: ${errorMessage3(error)}`);
2416
- }
3032
+ if (!sharedApi || isPluginExecutionBlocked(safetySnapshot)) {
3033
+ return;
2417
3034
  }
2418
3035
  try {
2419
- registerTailnetInternalRoutes(api);
3036
+ sharedApi.registerCoreGatewayMethods();
2420
3037
  } catch (error) {
2421
- console.warn(`[squad-openclaw] internal route registration failed: ${errorMessage3(error)}`);
3038
+ safetySnapshot = recordPluginFailure(
3039
+ "STARTUP_GATEWAY_METHODS_FAILED",
3040
+ errorMessage4(error),
3041
+ "Fix gateway method registration errors and run squad.plugin.recover."
3042
+ );
3043
+ console.warn(`[squad-openclaw] core gateway method registration failed: ${errorMessage4(error)}`);
3044
+ }
3045
+ if (isPluginExecutionBlocked(safetySnapshot)) {
3046
+ return;
2422
3047
  }
2423
3048
  void withTimeout(runStartupMigrations(api), STARTUP_MIGRATIONS_TIMEOUT_MS, "startup migrations").catch((error) => {
3049
+ const errorId = error instanceof TimeoutError ? "STARTUP_MIGRATIONS_TIMEOUT" : "STARTUP_MIGRATIONS_FAILED";
3050
+ const snapshot = recordPluginFailure(
3051
+ errorId,
3052
+ errorMessage4(error),
3053
+ "Inspect startup migration logs and run squad.plugin.recover after fixing migration failures."
3054
+ );
2424
3055
  if (error instanceof TimeoutError) {
2425
3056
  console.warn(`[squad-openclaw] startup migrations timed out after ${STARTUP_MIGRATIONS_TIMEOUT_MS}ms`);
2426
- return;
3057
+ } else {
3058
+ console.warn(`[squad-openclaw] startup migrations failed: ${errorMessage4(error)}`);
3059
+ }
3060
+ if (snapshot.blocked) {
3061
+ console.warn(
3062
+ `[squad-openclaw] plugin moved to ${snapshot.state} after startup migration failure (${snapshot.reasonCode ?? "NO_REASON"})`
3063
+ );
2427
3064
  }
2428
- console.warn(`[squad-openclaw] startup migrations failed: ${errorMessage3(error)}`);
2429
3065
  });
2430
3066
  }
2431
3067
  export {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "squad-openclaw",
3
- "version": "2026.2.2703",
3
+ "version": "2026.2.2704",
4
4
  "description": "Entity registry, filesystem tools, and version management plugin for OpenClaw gateway",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",