failproofai 1.0.0-beta.2 → 1.0.0-beta.3
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.
- package/.next/standalone/.next/BUILD_ID +1 -1
- package/.next/standalone/.next/build-manifest.json +6 -6
- package/.next/standalone/.next/prerender-manifest.json +3 -3
- package/.next/standalone/.next/required-server-files.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_global-error.html +1 -1
- package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
- package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
- package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +3 -3
- package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +3 -3
- package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found/page/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_not-found.html +1 -1
- package/.next/standalone/.next/server/app/_not-found.rsc +15 -15
- package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +15 -15
- package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
- package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +10 -10
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
- package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/reminder/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/audit/page/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
- package/.next/standalone/.next/server/app/audit/page.js +1 -1
- package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/index.html +1 -1
- package/.next/standalone/.next/server/app/index.rsc +15 -15
- package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +15 -15
- package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +4 -4
- package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +10 -10
- package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/page/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/policies/page/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +30 -18
- package/.next/standalone/.next/server/app/policies/page.js +2 -2
- package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/projects/page/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0id_xf7._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__19120tr._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1fwl2mz._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1myjm-d._.js +1 -1
- package/.next/standalone/.next/server/chunks/node_modules_next_dist_esm_build_templates_app-route_17k9e3w.js +4 -4
- package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__02r5bgf._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0e8sjqm._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0lkzqax._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1ath6v_._.js → [root-of-the-server]__0ls0gsr._.js} +3 -3
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0qucxyj._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__170799-._.js → [root-of-the-server]__0spkm68._.js} +11 -11
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0u02miy._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1psjybo._.js → [root-of-the-server]__10s_z4l._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0114ewg._.js → [root-of-the-server]__1j4u3yd._.js} +3 -3
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0nb0syg._.js → [root-of-the-server]__1ky3im9._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1zqz4v8._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{node_modules_html-to-image_es_index_0oy_-36.js → node_modules_html-to-image_es_index_0jfts82.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_1ezd2jf._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_1tnuifj._.js +1 -1
- package/.next/standalone/.next/server/middleware-build-manifest.js +6 -6
- package/.next/standalone/.next/server/pages/404.html +1 -1
- package/.next/standalone/.next/server/pages/500.html +1 -1
- package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/server-reference-manifest.json +32 -20
- package/.next/standalone/.next/static/chunks/{3s7myk_co0gbh.js → 01h6lfjz1_oie.js} +1 -1
- package/.next/standalone/.next/static/chunks/{03fmihek9n986.js → 06zgzyh3wv1ig.js} +1 -1
- package/.next/standalone/.next/static/chunks/{0fi645si-uiiq.js → 0a7k_x4rp_97z.js} +1 -1
- package/.next/standalone/.next/static/chunks/{2sbelanzvxvv0.js → 0cvd6bcdwchc_.js} +1 -1
- package/.next/standalone/.next/static/chunks/{18rvdw5v72364.js → 13-obgukzg_ke.js} +1 -1
- package/.next/standalone/.next/static/chunks/16p68cybetr31.js +1 -0
- package/.next/standalone/.next/static/chunks/{2j94uwkb4s3qr.js → 1h0_yaoq5f3yx.js} +1 -1
- package/.next/standalone/.next/static/chunks/{3vs4ay36tjds_.js → 29g8ykjk6sx_8.js} +1 -1
- package/.next/standalone/.next/static/chunks/{3hp8ulqknybil.js → 2rqm-klet_gbl.js} +1 -1
- package/.next/standalone/.next/static/chunks/{31feojxhjyjpl.js → 2uvyh8y0naofj.js} +1 -1
- package/.next/standalone/.next/static/chunks/31coz11ia5vc4.js +1 -0
- package/.next/standalone/.next/static/chunks/3p-n_j80zlzi3.js +6 -0
- package/.next/standalone/.next/static/chunks/3zdwnnthucs_1.css +2 -0
- package/.next/standalone/.next/static/chunks/{turbopack-1lsv92x7klwrn.js → turbopack-3diub4ctvn1n0.js} +1 -1
- package/.next/standalone/Cargo.lock +1992 -198
- package/.next/standalone/Cargo.toml +1 -1
- package/.next/standalone/app/actions/get-active-pauses.ts +16 -0
- package/.next/standalone/app/components/pause-notices.tsx +111 -0
- package/.next/standalone/app/policies/hooks-client.tsx +81 -6
- package/.next/standalone/crates/CLOUD_POLICIES.md +136 -0
- package/.next/standalone/crates/failproofaid/Cargo.toml +7 -0
- package/.next/standalone/crates/failproofaid/src/cloud_client.rs +484 -0
- package/.next/standalone/crates/failproofaid/src/cloud_policies.rs +921 -0
- package/.next/standalone/crates/failproofaid/src/main.rs +452 -3
- package/.next/standalone/crates/failproofaid/src/paths.rs +29 -0
- package/.next/standalone/crates/fpai-collect/Cargo.toml +43 -0
- package/.next/standalone/crates/fpai-collect/src/config.rs +412 -0
- package/.next/standalone/crates/fpai-collect/src/cursor.rs +520 -0
- package/.next/standalone/crates/fpai-collect/src/delivery.rs +472 -0
- package/.next/standalone/crates/fpai-collect/src/filetail.rs +484 -0
- package/.next/standalone/crates/fpai-collect/src/health.rs +369 -0
- package/.next/standalone/crates/fpai-collect/src/lib.rs +34 -0
- package/.next/standalone/crates/fpai-collect/src/redact.rs +516 -0
- package/.next/standalone/crates/fpai-collect/src/sources/claude/mod.rs +279 -0
- package/.next/standalone/crates/fpai-collect/src/sources/claude/transform.rs +619 -0
- package/.next/standalone/crates/fpai-collect/src/sources/codex/mod.rs +156 -0
- package/.next/standalone/crates/fpai-collect/src/sources/codex/transform.rs +715 -0
- package/.next/standalone/crates/fpai-collect/src/sources/copilot/mod.rs +158 -0
- package/.next/standalone/crates/fpai-collect/src/sources/copilot/transform.rs +512 -0
- package/.next/standalone/crates/fpai-collect/src/sources/goose/mod.rs +511 -0
- package/.next/standalone/crates/fpai-collect/src/sources/goose/transform.rs +586 -0
- package/.next/standalone/crates/fpai-collect/src/sources/hermes/mod.rs +318 -0
- package/.next/standalone/crates/fpai-collect/src/sources/hermes/transform.rs +579 -0
- package/.next/standalone/crates/fpai-collect/src/sources/hooks/mod.rs +310 -0
- package/.next/standalone/crates/fpai-collect/src/sources/hooks/transform.rs +448 -0
- package/.next/standalone/crates/fpai-collect/src/sources/mod.rs +23 -0
- package/.next/standalone/crates/fpai-collect/src/sources/openclaw/mod.rs +176 -0
- package/.next/standalone/crates/fpai-collect/src/sources/openclaw/transform.rs +500 -0
- package/.next/standalone/crates/fpai-collect/src/sources/opencode/mod.rs +315 -0
- package/.next/standalone/crates/fpai-collect/src/sources/opencode/transform.rs +493 -0
- package/.next/standalone/crates/fpai-collect/src/sources/pi/mod.rs +201 -0
- package/.next/standalone/crates/fpai-collect/src/sources/pi/transform.rs +533 -0
- package/.next/standalone/crates/fpai-collect/src/spool.rs +458 -0
- package/.next/standalone/crates/fpai-collect/src/sqlitepoll.rs +387 -0
- package/.next/standalone/crates/fpai-collect/src/supervisor.rs +423 -0
- package/.next/standalone/crates/fpai-collect/src/uploader.rs +600 -0
- package/.next/standalone/crates/fpai-collect/tests/claude_source.rs +738 -0
- package/.next/standalone/crates/fpai-collect/tests/claude_subagents.rs +393 -0
- package/.next/standalone/crates/fpai-collect/tests/codex_source.rs +975 -0
- package/.next/standalone/crates/fpai-collect/tests/config.rs +286 -0
- package/.next/standalone/crates/fpai-collect/tests/copilot_source.rs +1019 -0
- package/.next/standalone/crates/fpai-collect/tests/delivery.rs +281 -0
- package/.next/standalone/crates/fpai-collect/tests/goose_source.rs +1008 -0
- package/.next/standalone/crates/fpai-collect/tests/hermes_source.rs +1129 -0
- package/.next/standalone/crates/fpai-collect/tests/hooks_source.rs +621 -0
- package/.next/standalone/crates/fpai-collect/tests/openclaw_source.rs +854 -0
- package/.next/standalone/crates/fpai-collect/tests/opencode_source.rs +1478 -0
- package/.next/standalone/crates/fpai-collect/tests/pi_source.rs +1191 -0
- package/.next/standalone/crates/fpai-collect/tests/supervisor.rs +290 -0
- package/.next/standalone/crates/fpai-collect/tests/uploader.rs +506 -0
- package/.next/standalone/package.json +2 -2
- package/.next/standalone/server.js +1 -1
- package/bin/failproofai.mjs +129 -0
- package/dist/cli.mjs +2195 -770
- package/dist/worker.mjs +365 -52
- package/package.json +2 -2
- package/src/hooks/builtin-policies.ts +139 -0
- package/src/hooks/cloud-connection.ts +205 -0
- package/src/hooks/cloud-enrollment-cli.ts +227 -0
- package/src/hooks/cloud-enrollment.ts +173 -0
- package/src/hooks/cloud-managed-policies.ts +138 -0
- package/src/hooks/collector-config.ts +226 -0
- package/src/hooks/configure-wizard.ts +221 -5
- package/src/hooks/custom-hooks-loader.ts +69 -25
- package/src/hooks/daemon-client.ts +18 -0
- package/src/hooks/handler.ts +144 -6
- package/src/hooks/hook-activity-store.ts +50 -0
- package/src/hooks/loader-utils.ts +6 -4
- package/src/hooks/policy-types.ts +35 -0
- package/src/hooks/session-pause-cli.ts +157 -0
- package/src/hooks/session-pause.ts +236 -0
- package/src/hooks/tui.ts +115 -0
- package/.next/standalone/.next/static/chunks/0zbxssxh53n-3.js +0 -1
- package/.next/standalone/.next/static/chunks/2pujpnfx4pws6.css +0 -2
- package/.next/standalone/.next/static/chunks/33u59vf_8xpd-.js +0 -1
- package/.next/standalone/.next/static/chunks/40k1_v4os4cw6.js +0 -6
- /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → pM1veX5Llx4YKEaiFc8Sf}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → pM1veX5Llx4YKEaiFc8Sf}/_clientMiddlewareManifest.js +0 -0
- /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → pM1veX5Llx4YKEaiFc8Sf}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Connecting a machine to Failproof Cloud.
|
|
3
|
+
*
|
|
4
|
+
* The credential lives in a file the daemon reads, NOT in the service unit.
|
|
5
|
+
* `daemon-service.ts` installs that unit at mode 0644 into
|
|
6
|
+
* /etc/systemd/system (and the launchd plist likewise), both root-owned and
|
|
7
|
+
* world-readable — so an `Environment="FAILPROOFAI_CLOUD_TOKEN=…"` line would
|
|
8
|
+
* hand an organization-scoped bearer key to every local user, and
|
|
9
|
+
* `systemctl show` would print it back without any privilege at all.
|
|
10
|
+
*
|
|
11
|
+
* Keeping it out of the unit also means enrolment, re-enrolment, token
|
|
12
|
+
* rotation and disconnect need no root — none of them touch a privileged
|
|
13
|
+
* path — and enrolment stops being welded to service installation, so an
|
|
14
|
+
* already-running daemon can be connected without reinstalling anything.
|
|
15
|
+
*
|
|
16
|
+
* Environment variables still win over this file, so CI, containers and the
|
|
17
|
+
* existing tests are unaffected.
|
|
18
|
+
*/
|
|
19
|
+
import { existsSync, readFileSync, rmSync } from "node:fs";
|
|
20
|
+
import { homedir } from "node:os";
|
|
21
|
+
import { join } from "node:path";
|
|
22
|
+
import { writeJsonAtomically } from "../../lib/atomic-write";
|
|
23
|
+
import { fetchWithTimeout, isAbortError } from "../../lib/fetch-with-timeout";
|
|
24
|
+
|
|
25
|
+
const SCHEMA_VERSION = 1;
|
|
26
|
+
const VERIFY_TIMEOUT_MS = 10_000;
|
|
27
|
+
|
|
28
|
+
export interface CloudCredentials {
|
|
29
|
+
url: string;
|
|
30
|
+
machineId: string;
|
|
31
|
+
token: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface StoredCredentials extends CloudCredentials {
|
|
35
|
+
schemaVersion: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** `~/.failproofai/cloud.json`. The override is honoured by the daemon too. */
|
|
39
|
+
export function cloudCredentialPath(): string {
|
|
40
|
+
const override = process.env.FAILPROOFAI_CLOUD_CREDENTIALS;
|
|
41
|
+
if (override) return override;
|
|
42
|
+
return join(homedir(), ".failproofai", "cloud.json");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Rejects a URL that would put a bearer token on the wire in clear.
|
|
47
|
+
*
|
|
48
|
+
* Loopback stays allowed over http, because that is exactly what the
|
|
49
|
+
* enterprise doc's local walkthrough does (`http://localhost:8080`) and there
|
|
50
|
+
* is no network to intercept.
|
|
51
|
+
*/
|
|
52
|
+
export function validateCloudUrl(raw: string): { ok: true; url: string } | { ok: false; reason: string } {
|
|
53
|
+
let parsed: URL;
|
|
54
|
+
try {
|
|
55
|
+
parsed = new URL(raw);
|
|
56
|
+
} catch {
|
|
57
|
+
return { ok: false, reason: `"${raw}" is not a valid URL. Expected something like https://be.failproof.ai` };
|
|
58
|
+
}
|
|
59
|
+
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
|
60
|
+
return { ok: false, reason: `Unsupported scheme "${parsed.protocol}" — use https (or http for localhost).` };
|
|
61
|
+
}
|
|
62
|
+
if (parsed.protocol === "http:") {
|
|
63
|
+
const host = parsed.hostname;
|
|
64
|
+
const loopback = host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]";
|
|
65
|
+
if (!loopback) {
|
|
66
|
+
return {
|
|
67
|
+
ok: false,
|
|
68
|
+
reason:
|
|
69
|
+
`Refusing to send the machine token to ${parsed.origin} over plain http. ` +
|
|
70
|
+
`Use https, or http only for localhost during development.`,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return { ok: true, url: parsed.origin + (parsed.pathname === "/" ? "" : parsed.pathname.replace(/\/$/, "")) };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Last four characters only — enough to tell two keys apart, useless if leaked. */
|
|
78
|
+
export function maskToken(token: string): string {
|
|
79
|
+
if (token.length <= 4) return "****";
|
|
80
|
+
return `****${token.slice(-4)}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function readCloudCredentials(): CloudCredentials | null {
|
|
84
|
+
const path = cloudCredentialPath();
|
|
85
|
+
if (!existsSync(path)) return null;
|
|
86
|
+
try {
|
|
87
|
+
const raw = JSON.parse(readFileSync(path, "utf8")) as Partial<StoredCredentials>;
|
|
88
|
+
if (raw.schemaVersion !== SCHEMA_VERSION) return null;
|
|
89
|
+
if (typeof raw.url !== "string" || typeof raw.machineId !== "string" || typeof raw.token !== "string") {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
if (!raw.url || !raw.machineId || !raw.token) return null;
|
|
93
|
+
return { url: raw.url, machineId: raw.machineId, token: raw.token };
|
|
94
|
+
} catch {
|
|
95
|
+
// Unreadable or malformed reads as "not connected" rather than throwing:
|
|
96
|
+
// this is consulted by `--status`, and a corrupt file should be reported
|
|
97
|
+
// as disconnected, not crash the command.
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function writeCloudCredentials(creds: CloudCredentials): void {
|
|
103
|
+
// 0600 inside a 0700 dir — the default for writeJsonAtomically, and the
|
|
104
|
+
// whole reason this is a file of ours rather than a line in the unit.
|
|
105
|
+
writeJsonAtomically(cloudCredentialPath(), { schemaVersion: SCHEMA_VERSION, ...creds });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** True if there was something to remove. */
|
|
109
|
+
export function clearCloudCredentials(): boolean {
|
|
110
|
+
const path = cloudCredentialPath();
|
|
111
|
+
if (!existsSync(path)) return false;
|
|
112
|
+
try {
|
|
113
|
+
rmSync(path);
|
|
114
|
+
return true;
|
|
115
|
+
} catch {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export type VerifyResult =
|
|
121
|
+
| { ok: true; policyCount: number; generation: number }
|
|
122
|
+
| { ok: false; reason: string };
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Prove the credentials work before reporting success.
|
|
126
|
+
*
|
|
127
|
+
* Writing a credential file and saying nothing means a typo, a revoked key or
|
|
128
|
+
* a key missing `policies:pull` surfaces much later — as a policy that
|
|
129
|
+
* mysteriously never arrives, which is the worst moment to discover it. So
|
|
130
|
+
* enrolment makes exactly the request the daemon will make and reports what
|
|
131
|
+
* came back.
|
|
132
|
+
*/
|
|
133
|
+
export async function verifyCloudCredentials(creds: CloudCredentials): Promise<VerifyResult> {
|
|
134
|
+
const url = `${creds.url}/enforcement/v1/desired-state?machineId=${encodeURIComponent(creds.machineId)}`;
|
|
135
|
+
let response: Response;
|
|
136
|
+
try {
|
|
137
|
+
response = await fetchWithTimeout(
|
|
138
|
+
url,
|
|
139
|
+
{ headers: { Authorization: `Bearer ${creds.token}` } },
|
|
140
|
+
VERIFY_TIMEOUT_MS,
|
|
141
|
+
);
|
|
142
|
+
} catch (err) {
|
|
143
|
+
if (isAbortError(err)) return { ok: false, reason: `No response from ${creds.url} within ${VERIFY_TIMEOUT_MS / 1000}s.` };
|
|
144
|
+
return { ok: false, reason: `Could not reach ${creds.url}: ${err instanceof Error ? err.message : String(err)}` };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (response.status === 401) {
|
|
148
|
+
return { ok: false, reason: "The server rejected this token (401). Check the key was copied whole." };
|
|
149
|
+
}
|
|
150
|
+
if (response.status === 403) {
|
|
151
|
+
return {
|
|
152
|
+
ok: false,
|
|
153
|
+
reason: "This key is valid but lacks the `policies:pull` permission (403). Create a machine key with that permission.",
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
if (!response.ok) {
|
|
157
|
+
return { ok: false, reason: `The server answered ${response.status} ${response.statusText}.` };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
try {
|
|
161
|
+
const body = (await response.json()) as { policies?: unknown[]; generation?: number };
|
|
162
|
+
return {
|
|
163
|
+
ok: true,
|
|
164
|
+
policyCount: Array.isArray(body.policies) ? body.policies.length : 0,
|
|
165
|
+
generation: typeof body.generation === "number" ? body.generation : 0,
|
|
166
|
+
};
|
|
167
|
+
} catch {
|
|
168
|
+
return {
|
|
169
|
+
ok: false,
|
|
170
|
+
reason: `${creds.url} answered, but not with a desired-state document. Is this a Failproof Cloud URL?`,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads the daemon-owned active cloud policy generation for the TypeScript
|
|
3
|
+
* evaluator. The Rust reconciler is responsible for downloading and repairing
|
|
4
|
+
* artifacts; this boundary independently verifies the digest immediately
|
|
5
|
+
* before import so the worker never knowingly executes modified bytes.
|
|
6
|
+
*/
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import { isAbsolute, relative, resolve } from "node:path";
|
|
11
|
+
|
|
12
|
+
const ACTIVE_SCHEMA_VERSION = 1;
|
|
13
|
+
const SHA256_RE = /^[a-f0-9]{64}$/;
|
|
14
|
+
const POLICY_ID_RE = /^[A-Za-z0-9._-]{1,128}$/;
|
|
15
|
+
|
|
16
|
+
/** What an assignment may do. See PolicyEffect in cloud_policies.rs. */
|
|
17
|
+
export type PolicyEffect = "enforce" | "observe";
|
|
18
|
+
|
|
19
|
+
export interface CloudManagedPolicyArtifact {
|
|
20
|
+
id: string;
|
|
21
|
+
revision: number;
|
|
22
|
+
/**
|
|
23
|
+
* `observe` policies are evaluated exactly like any other and then have their
|
|
24
|
+
* verdict discarded, so a rollout can be measured against real traffic before
|
|
25
|
+
* it can break anyone's work. Absent means `enforce`: the default has to be
|
|
26
|
+
* the one that keeps enforcing, or a manifest written before observe mode
|
|
27
|
+
* would silently downgrade a machine to observation.
|
|
28
|
+
*/
|
|
29
|
+
effect: PolicyEffect;
|
|
30
|
+
sha256: string;
|
|
31
|
+
path: string;
|
|
32
|
+
generation: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface ActiveManifest {
|
|
36
|
+
schemaVersion: number;
|
|
37
|
+
generation: number;
|
|
38
|
+
policies: Array<{
|
|
39
|
+
id: string;
|
|
40
|
+
revision: number;
|
|
41
|
+
effect?: string;
|
|
42
|
+
sha256: string;
|
|
43
|
+
path: string;
|
|
44
|
+
}>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function cloudManagedPolicyRoot(): string {
|
|
48
|
+
return (
|
|
49
|
+
process.env.FAILPROOFAI_CLOUD_POLICY_DIR ??
|
|
50
|
+
resolve(homedir(), ".failproofai", "policies", "cloud-managed")
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function parseManifest(value: unknown): ActiveManifest {
|
|
55
|
+
if (!value || typeof value !== "object") throw new Error("active manifest is not an object");
|
|
56
|
+
const manifest = value as Partial<ActiveManifest>;
|
|
57
|
+
if (manifest.schemaVersion !== ACTIVE_SCHEMA_VERSION) {
|
|
58
|
+
throw new Error(`unsupported active manifest schema ${String(manifest.schemaVersion)}`);
|
|
59
|
+
}
|
|
60
|
+
if (!Number.isSafeInteger(manifest.generation) || (manifest.generation ?? -1) < 0) {
|
|
61
|
+
throw new Error("active manifest generation is invalid");
|
|
62
|
+
}
|
|
63
|
+
if (!Array.isArray(manifest.policies)) throw new Error("active manifest policies is not an array");
|
|
64
|
+
return manifest as ActiveManifest;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function resolveManagedPath(root: string, candidate: string): string {
|
|
68
|
+
if (!candidate || isAbsolute(candidate)) throw new Error(`unsafe managed policy path ${JSON.stringify(candidate)}`);
|
|
69
|
+
const absolute = resolve(root, candidate);
|
|
70
|
+
const lexicalRelative = relative(root, absolute);
|
|
71
|
+
if (lexicalRelative.startsWith("..") || isAbsolute(lexicalRelative)) {
|
|
72
|
+
throw new Error(`managed policy path escapes its root: ${JSON.stringify(candidate)}`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Reject a symlinked artifact or generation directory escaping the managed
|
|
76
|
+
// root. The current daemon is same-user, but following an arbitrary path is
|
|
77
|
+
// still unnecessary ambient authority and becomes dangerous if its service
|
|
78
|
+
// identity is hardened later.
|
|
79
|
+
const realRoot = realpathSync(root);
|
|
80
|
+
const realFile = realpathSync(absolute);
|
|
81
|
+
const physicalRelative = relative(realRoot, realFile);
|
|
82
|
+
if (physicalRelative.startsWith("..") || isAbsolute(physicalRelative)) {
|
|
83
|
+
throw new Error(`managed policy symlink escapes its root: ${JSON.stringify(candidate)}`);
|
|
84
|
+
}
|
|
85
|
+
return realFile;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function readActiveCloudManagedPolicies(): CloudManagedPolicyArtifact[] {
|
|
89
|
+
const root = cloudManagedPolicyRoot();
|
|
90
|
+
const activePath = resolve(root, "active.json");
|
|
91
|
+
if (!existsSync(activePath)) return [];
|
|
92
|
+
|
|
93
|
+
let raw: unknown;
|
|
94
|
+
try {
|
|
95
|
+
raw = JSON.parse(readFileSync(activePath, "utf8"));
|
|
96
|
+
} catch (err) {
|
|
97
|
+
throw new Error(`failed to read cloud-managed active manifest: ${err instanceof Error ? err.message : String(err)}`);
|
|
98
|
+
}
|
|
99
|
+
const manifest = parseManifest(raw);
|
|
100
|
+
const seen = new Set<string>();
|
|
101
|
+
|
|
102
|
+
return manifest.policies.map((policy) => {
|
|
103
|
+
if (!POLICY_ID_RE.test(policy.id) || policy.id === "." || policy.id === "..") {
|
|
104
|
+
throw new Error(`unsafe cloud-managed policy id ${JSON.stringify(policy.id)}`);
|
|
105
|
+
}
|
|
106
|
+
if (!Number.isSafeInteger(policy.revision) || policy.revision < 0) {
|
|
107
|
+
throw new Error(`invalid revision for cloud-managed policy ${policy.id}`);
|
|
108
|
+
}
|
|
109
|
+
if (!SHA256_RE.test(policy.sha256)) {
|
|
110
|
+
throw new Error(`invalid SHA-256 for cloud-managed policy ${policy.id}`);
|
|
111
|
+
}
|
|
112
|
+
if (seen.has(policy.id)) throw new Error(`duplicate cloud-managed policy id ${policy.id}`);
|
|
113
|
+
seen.add(policy.id);
|
|
114
|
+
|
|
115
|
+
const path = resolveManagedPath(root, policy.path);
|
|
116
|
+
const bytes = readFileSync(path);
|
|
117
|
+
const actual = createHash("sha256").update(bytes).digest("hex");
|
|
118
|
+
if (actual !== policy.sha256) {
|
|
119
|
+
throw new Error(
|
|
120
|
+
`cloud-managed policy ${policy.id} failed integrity verification: expected ${policy.sha256}, got ${actual}`,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
if (policy.effect !== undefined && policy.effect !== "enforce" && policy.effect !== "observe") {
|
|
124
|
+
// Guessing would mean either enforcing something cloud did not ask to
|
|
125
|
+
// enforce, or observing something it wanted enforced. Both are worse than
|
|
126
|
+
// refusing the generation.
|
|
127
|
+
throw new Error(`unknown effect ${JSON.stringify(policy.effect)} for cloud-managed policy ${policy.id}`);
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
id: policy.id,
|
|
131
|
+
revision: policy.revision,
|
|
132
|
+
effect: (policy.effect as PolicyEffect | undefined) ?? "enforce",
|
|
133
|
+
sha256: policy.sha256,
|
|
134
|
+
path,
|
|
135
|
+
generation: manifest.generation,
|
|
136
|
+
};
|
|
137
|
+
});
|
|
138
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Writing the collector's configuration from the setup wizard.
|
|
3
|
+
*
|
|
4
|
+
* Two files, deliberately:
|
|
5
|
+
*
|
|
6
|
+
* ~/.failproofai/ingest.json mode 0600, the credential ONLY
|
|
7
|
+
* ~/.failproofai/policies-config.json the non-secret settings
|
|
8
|
+
*
|
|
9
|
+
* The split is not tidiness. `policies-config.json` is written with a bare
|
|
10
|
+
* `writeFileSync`, so it inherits the umask and lands at 0664 on a normal
|
|
11
|
+
* machine — inside a `~/.failproofai/` that is itself 0775. An API key there
|
|
12
|
+
* would be readable by every local user on the box. `~/.agenteye/cli.json`
|
|
13
|
+
* already stores its session token at 0600, so the correct precedent existed.
|
|
14
|
+
*
|
|
15
|
+
* The Rust daemon reads both (see `crates/fpai-collect/src/config.rs`); this is
|
|
16
|
+
* the only thing that writes them.
|
|
17
|
+
*/
|
|
18
|
+
import {
|
|
19
|
+
writeFileSync,
|
|
20
|
+
mkdirSync,
|
|
21
|
+
existsSync,
|
|
22
|
+
chmodSync,
|
|
23
|
+
statSync,
|
|
24
|
+
readFileSync,
|
|
25
|
+
rmSync,
|
|
26
|
+
} from "node:fs";
|
|
27
|
+
import { join } from "node:path";
|
|
28
|
+
import { homedir } from "node:os";
|
|
29
|
+
|
|
30
|
+
import { readHooksConfig, writeHooksConfig } from "./hooks-config";
|
|
31
|
+
|
|
32
|
+
/** The hosted ingest endpoint — a COMPLETE endpoint, not a base to join onto. */
|
|
33
|
+
export const DEFAULT_INGEST_URL = "https://server.befailproof.ai/events";
|
|
34
|
+
|
|
35
|
+
export interface IngestCredential {
|
|
36
|
+
url: string;
|
|
37
|
+
key: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface CollectorSettings {
|
|
41
|
+
/** Ship agent session transcripts. Separate opt-in from having a key. */
|
|
42
|
+
sessions: boolean;
|
|
43
|
+
/** Ship hook activity. */
|
|
44
|
+
hooks: boolean;
|
|
45
|
+
hooksVerbosity?: "all" | "decisions" | "off";
|
|
46
|
+
redact?: "minimal" | "off";
|
|
47
|
+
environment?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function failproofaiHome(): string {
|
|
51
|
+
return process.env.FAILPROOFAI_HOME || join(homedir(), ".failproofai");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function ingestPath(): string {
|
|
55
|
+
return join(failproofaiHome(), "ingest.json");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Whether an ingest credential is already configured on this machine. */
|
|
59
|
+
export function hasIngestCredential(): boolean {
|
|
60
|
+
return existsSync(ingestPath());
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The stored ingest credential, or null.
|
|
65
|
+
*
|
|
66
|
+
* Malformed JSON reads as absent rather than throwing: this is called from
|
|
67
|
+
* `--status`, and a status command that crashes on a corrupt file is worse
|
|
68
|
+
* than one reporting the capability as unconfigured — which, given the daemon
|
|
69
|
+
* cannot read it either, is the truth.
|
|
70
|
+
*/
|
|
71
|
+
export function readIngestCredential(): IngestCredential | null {
|
|
72
|
+
try {
|
|
73
|
+
const raw = JSON.parse(readFileSync(ingestPath(), "utf8")) as Partial<IngestCredential>;
|
|
74
|
+
if (typeof raw.url !== "string" || typeof raw.key !== "string") return null;
|
|
75
|
+
if (!raw.url || !raw.key) return null;
|
|
76
|
+
return { url: raw.url, key: raw.key };
|
|
77
|
+
} catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Remove the ingest credential. Returns whether a file was actually removed. */
|
|
83
|
+
export function clearIngestCredential(): boolean {
|
|
84
|
+
try {
|
|
85
|
+
rmSync(ingestPath());
|
|
86
|
+
return true;
|
|
87
|
+
} catch {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Write the credential at owner-only permissions, and tighten the home that
|
|
94
|
+
* holds it.
|
|
95
|
+
*
|
|
96
|
+
* A 0600 file inside a 0775 directory is still reachable by every local user,
|
|
97
|
+
* so the directory is tightened too. Both are best-effort on a filesystem
|
|
98
|
+
* without POSIX modes; the write itself is not.
|
|
99
|
+
*/
|
|
100
|
+
export function writeIngestCredential(cred: IngestCredential): string {
|
|
101
|
+
const home = failproofaiHome();
|
|
102
|
+
mkdirSync(home, { recursive: true });
|
|
103
|
+
try {
|
|
104
|
+
const mode = statSync(home).mode & 0o777;
|
|
105
|
+
if (mode & 0o077) chmodSync(home, 0o700);
|
|
106
|
+
} catch {
|
|
107
|
+
// Not fatal: the file's own 0600 is the primary protection.
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const path = ingestPath();
|
|
111
|
+
// `mode` on writeFileSync applies only when the file is CREATED, so an
|
|
112
|
+
// existing over-permissive file would keep its mode without the chmod.
|
|
113
|
+
writeFileSync(path, JSON.stringify(cred, null, 2) + "\n", { mode: 0o600 });
|
|
114
|
+
try {
|
|
115
|
+
chmodSync(path, 0o600);
|
|
116
|
+
} catch {
|
|
117
|
+
/* best effort */
|
|
118
|
+
}
|
|
119
|
+
return path;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Merge the collector block into the shared (non-secret) config. */
|
|
123
|
+
export function writeCollectorSettings(settings: CollectorSettings): void {
|
|
124
|
+
const config = readHooksConfig();
|
|
125
|
+
writeHooksConfig({
|
|
126
|
+
...config,
|
|
127
|
+
collector: {
|
|
128
|
+
sessions: settings.sessions,
|
|
129
|
+
hooks: settings.hooks,
|
|
130
|
+
hooksVerbosity: settings.hooksVerbosity ?? "decisions",
|
|
131
|
+
redact: settings.redact ?? "minimal",
|
|
132
|
+
environment: settings.environment ?? "local",
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export type KeyCheck =
|
|
138
|
+
| { ok: true }
|
|
139
|
+
| { ok: false; reason: string };
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Verify a key against the endpoint before writing it anywhere.
|
|
143
|
+
*
|
|
144
|
+
* Sends an EMPTY body: ingest answers `{"accepted":0,"skipped":0}` for it, so
|
|
145
|
+
* the round-trip proves the URL resolves and the key authenticates without
|
|
146
|
+
* creating a spurious event in the user's dashboard.
|
|
147
|
+
*
|
|
148
|
+
* Failing here at setup is the whole point — otherwise a typo'd key is only
|
|
149
|
+
* discovered later as a silent pile of 401s in `failed/`, which reads like a
|
|
150
|
+
* server problem rather than a typo.
|
|
151
|
+
*/
|
|
152
|
+
export async function validateIngestKey(
|
|
153
|
+
cred: IngestCredential,
|
|
154
|
+
fetchImpl: typeof fetch = fetch,
|
|
155
|
+
timeoutMs = 10_000,
|
|
156
|
+
): Promise<KeyCheck> {
|
|
157
|
+
const controller = new AbortController();
|
|
158
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
159
|
+
try {
|
|
160
|
+
const res = await fetchImpl(cred.url, {
|
|
161
|
+
method: "POST",
|
|
162
|
+
headers: {
|
|
163
|
+
Authorization: `Bearer ${cred.key}`,
|
|
164
|
+
"Content-Type": "application/x-ndjson",
|
|
165
|
+
},
|
|
166
|
+
body: "",
|
|
167
|
+
// Never follow a redirect. `fetch` follows by default, and the dashboard
|
|
168
|
+
// (which sits on a different port of the same host, and is printed during
|
|
169
|
+
// setup right beside the API) answers POST /events with a 307 to its
|
|
170
|
+
// login page — which then returns 200. So `res.ok` was true for an
|
|
171
|
+
// endpoint that authenticates nothing and stores nothing: the credential
|
|
172
|
+
// was written, setup reported success, and every batch afterwards was
|
|
173
|
+
// POSTed into a login form and lost. Confusing the two URLs is the most
|
|
174
|
+
// likely mistake available here.
|
|
175
|
+
redirect: "manual",
|
|
176
|
+
signal: controller.signal,
|
|
177
|
+
});
|
|
178
|
+
if (res.status >= 300 && res.status < 400) {
|
|
179
|
+
return {
|
|
180
|
+
ok: false,
|
|
181
|
+
reason:
|
|
182
|
+
"that URL redirects rather than accepting events — it looks like the dashboard, not the ingest API",
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
if (res.status === 401 || res.status === 403) {
|
|
186
|
+
return { ok: false, reason: `the server rejected that key (${res.status})` };
|
|
187
|
+
}
|
|
188
|
+
if (res.status === 404) {
|
|
189
|
+
return { ok: false, reason: `no ingest endpoint at that URL (404)` };
|
|
190
|
+
}
|
|
191
|
+
if (!res.ok) {
|
|
192
|
+
return { ok: false, reason: `the server answered ${res.status}` };
|
|
193
|
+
}
|
|
194
|
+
// A 2xx is not enough either. Ingest answers `{"accepted":N,"skipped":M}`;
|
|
195
|
+
// anything else returning 200 (a proxy, a static host, a catch-all router)
|
|
196
|
+
// would otherwise pass. Requiring the shape is what actually proves this is
|
|
197
|
+
// the endpoint the uploader will be talking to.
|
|
198
|
+
let body: unknown;
|
|
199
|
+
try {
|
|
200
|
+
body = await res.json();
|
|
201
|
+
} catch {
|
|
202
|
+
return {
|
|
203
|
+
ok: false,
|
|
204
|
+
reason: "that URL answered without an ingest response — it is not the events endpoint",
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
const shaped =
|
|
208
|
+
typeof body === "object" &&
|
|
209
|
+
body !== null &&
|
|
210
|
+
typeof (body as { accepted?: unknown }).accepted === "number";
|
|
211
|
+
if (!shaped) {
|
|
212
|
+
return {
|
|
213
|
+
ok: false,
|
|
214
|
+
reason: "that URL answered, but not with an ingest response — it is not the events endpoint",
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
return { ok: true };
|
|
218
|
+
} catch (err) {
|
|
219
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
220
|
+
// Deliberately does not echo the URL back: it may contain a hostname the
|
|
221
|
+
// user would rather not see in a shared terminal recording.
|
|
222
|
+
return { ok: false, reason: `could not reach the server (${msg})` };
|
|
223
|
+
} finally {
|
|
224
|
+
clearTimeout(timer);
|
|
225
|
+
}
|
|
226
|
+
}
|