@lifeaitools/clauth 1.30.6 → 1.30.7
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/.clauth-skill/SKILL.md +17 -75
- package/README.md +10 -70
- package/cli/api.js +11 -110
- package/cli/commands/scrub.js +109 -205
- package/cli/commands/serve.js +850 -3059
- package/cli/fingerprint.js +10 -0
- package/cli/index.js +58 -22
- package/cli/studio-debug.js +8 -679
- package/cli/supervisor-registry.js +440 -0
- package/cli/supervisor-registry.test.js +173 -0
- package/package.json +3 -10
- package/scripts/bin/bootstrap-linux +0 -0
- package/scripts/bin/bootstrap-macos +0 -0
- package/scripts/bin/bootstrap-win.exe +0 -0
- package/scripts/postinstall.js +0 -25
- package/cli/api.classify.test.js +0 -75
- package/cli/commands/agent-cron.js +0 -396
- package/cli/commands/agent-pool.js +0 -1962
- package/cli/commands/scrub.test.js +0 -115
- package/cli/enrollment-script.js +0 -82
- package/cli/webdav-service.js +0 -339
|
@@ -1,115 +0,0 @@
|
|
|
1
|
-
// node --test cli/commands/scrub.test.js
|
|
2
|
-
import assert from "node:assert/strict";
|
|
3
|
-
import fs from "node:fs";
|
|
4
|
-
import os from "node:os";
|
|
5
|
-
import path from "node:path";
|
|
6
|
-
import test from "node:test";
|
|
7
|
-
|
|
8
|
-
import {
|
|
9
|
-
PATTERNS,
|
|
10
|
-
findTranscripts,
|
|
11
|
-
scrubFile,
|
|
12
|
-
isSecretLike,
|
|
13
|
-
loadExtraPatterns,
|
|
14
|
-
sessionTargets,
|
|
15
|
-
} from "./scrub.js";
|
|
16
|
-
|
|
17
|
-
const GH_TOKEN = "ghp_0123456789abcdefABCDEF0123456789abcd"; // ghp_ + 36 chars
|
|
18
|
-
const VAULT_VALUE = "SuperSecretVaultValue_abc123XYZ"; // secret-like literal
|
|
19
|
-
const CUSTOM_SECRET = "MYCORP-TOKEN-998877"; // only an editable pattern catches it
|
|
20
|
-
|
|
21
|
-
function seedProjects() {
|
|
22
|
-
const root = fs.mkdtempSync(path.join(os.tmpdir(), "clauth-scrub-"));
|
|
23
|
-
const sess = path.join(root, "proj", "session-uuid");
|
|
24
|
-
const sidecarDir = path.join(sess, "tool-results");
|
|
25
|
-
fs.mkdirSync(sidecarDir, { recursive: true });
|
|
26
|
-
|
|
27
|
-
const jsonl = path.join(root, "proj", "session-uuid.jsonl");
|
|
28
|
-
const txt = path.join(sidecarDir, "toolu_abc.txt");
|
|
29
|
-
const body = `token=${GH_TOKEN} value=${VAULT_VALUE} custom=${CUSTOM_SECRET}\n`;
|
|
30
|
-
fs.writeFileSync(jsonl, `{"x":"${body.trim()}"}\n`, "utf-8");
|
|
31
|
-
fs.writeFileSync(txt, body, "utf-8"); // the sidecar that the old scrubber skipped
|
|
32
|
-
return { root, jsonl, txt };
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
test("findTranscripts discovers .jsonl AND tool-results/*.txt sidecars", () => {
|
|
36
|
-
const { root, jsonl, txt } = seedProjects();
|
|
37
|
-
const found = findTranscripts(root);
|
|
38
|
-
assert.ok(found.includes(jsonl), "should find the .jsonl transcript");
|
|
39
|
-
assert.ok(found.includes(txt), "should find the .txt sidecar (the previously-skipped leak path)");
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
test("scrubFile redacts the github token in BOTH the jsonl and the sidecar", () => {
|
|
43
|
-
const { jsonl, txt } = seedProjects();
|
|
44
|
-
for (const f of [jsonl, txt]) {
|
|
45
|
-
const n = scrubFile(f, { force: true, patterns: PATTERNS, literals: [] });
|
|
46
|
-
assert.ok(n >= 1, `expected >=1 redaction in ${path.basename(f)}`);
|
|
47
|
-
const after = fs.readFileSync(f, "utf-8");
|
|
48
|
-
assert.ok(!after.includes(GH_TOKEN), "github token must be gone");
|
|
49
|
-
assert.ok(after.includes("[GITHUB_TOKEN_REDACTED]"), "redaction marker present");
|
|
50
|
-
assert.ok(after.includes("[CLAUTH-SCRUBBED]"), "file stamped as scrubbed");
|
|
51
|
-
}
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
test("vault-value (literal) redaction removes an arbitrary secret regardless of format", () => {
|
|
55
|
-
const { txt } = seedProjects();
|
|
56
|
-
const n = scrubFile(txt, { force: true, patterns: [], literals: [{ name: "test-svc", value: VAULT_VALUE }] });
|
|
57
|
-
assert.equal(n, 1, "exactly one literal occurrence redacted");
|
|
58
|
-
const after = fs.readFileSync(txt, "utf-8");
|
|
59
|
-
assert.ok(!after.includes(VAULT_VALUE), "vault value must be gone");
|
|
60
|
-
assert.ok(after.includes("[CLAUTH:test-svc_REDACTED]"), "labelled vault redaction present");
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
test("editable patterns file catches a custom secret with no clauth release", () => {
|
|
64
|
-
const cfg = path.join(os.tmpdir(), `clauth-scrub-patterns-${Date.now()}.json`);
|
|
65
|
-
fs.writeFileSync(cfg, JSON.stringify([{ pattern: "MYCORP-TOKEN-\\d+", replacement: "[MYCORP_REDACTED]" }]), "utf-8");
|
|
66
|
-
const extra = loadExtraPatterns(cfg);
|
|
67
|
-
assert.equal(extra.length, 1, "one extra pattern loaded");
|
|
68
|
-
|
|
69
|
-
const { txt } = seedProjects();
|
|
70
|
-
const n = scrubFile(txt, { force: true, patterns: extra, literals: [] });
|
|
71
|
-
assert.ok(n >= 1, "custom pattern matched");
|
|
72
|
-
const after = fs.readFileSync(txt, "utf-8");
|
|
73
|
-
assert.ok(!after.includes(CUSTOM_SECRET), "custom secret gone");
|
|
74
|
-
assert.ok(after.includes("[MYCORP_REDACTED]"), "custom replacement applied");
|
|
75
|
-
fs.rmSync(cfg, { force: true });
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
test("isSecretLike: accepts real tokens, rejects short/url/whitespace values", () => {
|
|
79
|
-
assert.ok(isSecretLike(GH_TOKEN), "long token is secret-like");
|
|
80
|
-
assert.ok(isSecretLike(VAULT_VALUE), "31-char no-space value is secret-like");
|
|
81
|
-
assert.ok(!isSecretLike("short"), "short value rejected");
|
|
82
|
-
assert.ok(!isSecretLike("https://research.regendevcorp.com/mcp"), "plain URL rejected");
|
|
83
|
-
assert.ok(!isSecretLike("has spaces in it value"), "whitespace value rejected");
|
|
84
|
-
assert.ok(!isSecretLike(""), "empty rejected");
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
test("sessionTargets returns ONLY the ending session's transcript + its sidecars", () => {
|
|
88
|
-
const root = fs.mkdtempSync(path.join(os.tmpdir(), "clauth-sess-"));
|
|
89
|
-
const proj = path.join(root, "C--proj");
|
|
90
|
-
const sid = "11112222-3333-4444-5555-666677778888";
|
|
91
|
-
fs.mkdirSync(path.join(proj, sid, "tool-results"), { recursive: true });
|
|
92
|
-
const transcript = path.join(proj, `${sid}.jsonl`);
|
|
93
|
-
const sidecar = path.join(proj, sid, "tool-results", "toolu_x.txt");
|
|
94
|
-
const otherSession = path.join(proj, "99990000-aaaa-bbbb-cccc-ddddeeeeffff.jsonl");
|
|
95
|
-
fs.writeFileSync(transcript, "{}\n");
|
|
96
|
-
fs.writeFileSync(sidecar, "tool output\n");
|
|
97
|
-
fs.writeFileSync(otherSession, "{}\n"); // a DIFFERENT session — must NOT be included
|
|
98
|
-
|
|
99
|
-
const targets = sessionTargets({ transcript_path: transcript, session_id: sid });
|
|
100
|
-
assert.ok(targets.includes(transcript), "includes the session transcript");
|
|
101
|
-
assert.ok(targets.includes(sidecar), "includes the session's sidecar");
|
|
102
|
-
assert.ok(!targets.includes(otherSession), "does NOT include other sessions (session-only)");
|
|
103
|
-
assert.equal(targets.length, 2, "exactly the 2 session files");
|
|
104
|
-
|
|
105
|
-
assert.deepEqual(sessionTargets(null), [], "no hook input → no targets (caller falls back)");
|
|
106
|
-
assert.deepEqual(sessionTargets({ transcript_path: path.join(root, "nope.jsonl") }), [], "missing file → none");
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
test("loadExtraPatterns tolerates a missing/malformed file", () => {
|
|
110
|
-
assert.deepEqual(loadExtraPatterns(path.join(os.tmpdir(), "does-not-exist-xyz.json")), []);
|
|
111
|
-
const bad = path.join(os.tmpdir(), `clauth-bad-${Date.now()}.json`);
|
|
112
|
-
fs.writeFileSync(bad, "{ not json", "utf-8");
|
|
113
|
-
assert.deepEqual(loadExtraPatterns(bad), [], "malformed json → empty, never throws");
|
|
114
|
-
fs.rmSync(bad, { force: true });
|
|
115
|
-
});
|
package/cli/enrollment-script.js
DELETED
|
@@ -1,82 +0,0 @@
|
|
|
1
|
-
import fs from "fs";
|
|
2
|
-
import os from "os";
|
|
3
|
-
import path from "path";
|
|
4
|
-
|
|
5
|
-
function shellSingleQuote(value) {
|
|
6
|
-
return `'${String(value ?? "").replace(/'/g, "''")}'`;
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
function posixShellQuote(value) {
|
|
10
|
-
return `'${String(value ?? "").replace(/'/g, "'\\\"'\\\"'")}'`;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export function enrollmentScriptName(label, target = "windows") {
|
|
14
|
-
const slug = String(label || "new-computer")
|
|
15
|
-
.toLowerCase()
|
|
16
|
-
.replace(/[^a-z0-9]+/g, "-")
|
|
17
|
-
.replace(/^-+|-+$/g, "")
|
|
18
|
-
.slice(0, 40) || "new-computer";
|
|
19
|
-
return `clauth-enroll-${slug}${target === "linux" ? ".sh" : ".ps1"}`;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
function windowsScript({ supabaseUrl, anonKey, enrollmentCode }) {
|
|
23
|
-
return [
|
|
24
|
-
"$ErrorActionPreference = 'Stop'",
|
|
25
|
-
"$label = $env:COMPUTERNAME",
|
|
26
|
-
"if (-not $label) { $label = [System.Net.Dns]::GetHostName() }",
|
|
27
|
-
"npm install -g @lifeaitools/clauth@latest",
|
|
28
|
-
["clauth setup", `--supabase-url ${shellSingleQuote(supabaseUrl)}`, `--anon-key ${shellSingleQuote(anonKey)}`, `--enrollment-code ${shellSingleQuote(enrollmentCode)}`, "--label \"$label\""].join(" "),
|
|
29
|
-
"clauth serve install",
|
|
30
|
-
"$self = $PSCommandPath",
|
|
31
|
-
"Start-Process powershell.exe -WindowStyle Hidden -ArgumentList @('-NoProfile','-Command',\"Start-Sleep -Seconds 2; & ('Remove' + '-Item') -LiteralPath '$self' -Force -ErrorAction SilentlyContinue\")",
|
|
32
|
-
].join("\r\n");
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function linuxScript({ supabaseUrl, anonKey, enrollmentCode }) {
|
|
36
|
-
return [
|
|
37
|
-
"#!/usr/bin/env sh",
|
|
38
|
-
"set -eu",
|
|
39
|
-
"label=$(hostname)",
|
|
40
|
-
"as_root() { if [ \"$(id -u)\" -eq 0 ]; then \"$@\"; elif command -v sudo >/dev/null; then sudo \"$@\"; else echo 'Root or sudo is required to install prerequisites.' >&2; exit 1; fi; }",
|
|
41
|
-
"install_prerequisites() {",
|
|
42
|
-
" if command -v apt-get >/dev/null; then as_root apt-get update; as_root apt-get install -y nodejs npm openssl;",
|
|
43
|
-
" elif command -v dnf >/dev/null; then as_root dnf install -y nodejs npm openssl;",
|
|
44
|
-
" else echo 'Headless enrollment supports systemd hosts with apt-get or dnf. Install Node.js 18+, npm, and openssl, then rerun this script.' >&2; exit 1; fi",
|
|
45
|
-
"}",
|
|
46
|
-
"if ! command -v systemctl >/dev/null || ! command -v loginctl >/dev/null; then echo 'Headless enrollment requires systemd and loginctl.' >&2; exit 1; fi",
|
|
47
|
-
"if ! command -v node >/dev/null || ! command -v npm >/dev/null || ! command -v openssl >/dev/null; then install_prerequisites; fi",
|
|
48
|
-
"node_major=$(node -p \"process.versions.node.split('.')[0]\")",
|
|
49
|
-
"if [ \"$node_major\" -lt 18 ]; then echo 'Node.js 18+ is required.' >&2; exit 1; fi",
|
|
50
|
-
"if ! npm install -g @lifeaitools/clauth@latest; then as_root npm install -g @lifeaitools/clauth@latest; fi",
|
|
51
|
-
["clauth setup", `--supabase-url ${posixShellQuote(supabaseUrl)}`, `--anon-key ${posixShellQuote(anonKey)}`, `--enrollment-code ${posixShellQuote(enrollmentCode)}`, '--label "$label"'].join(" "),
|
|
52
|
-
"user_name=$(id -un)",
|
|
53
|
-
"if loginctl enable-linger \"$user_name\"; then echo \"Linger enabled for $user_name.\"; elif as_root loginctl enable-linger \"$user_name\"; then echo \"Linger enabled for $user_name.\"; else echo 'Could not enable linger for unattended restart.' >&2; exit 1; fi",
|
|
54
|
-
"if ! test -t 0; then echo 'Headless enrollment requires an interactive TTY to set the vault password.' >&2; exit 1; fi",
|
|
55
|
-
"restore_echo() { stty echo 2>/dev/null || true; }",
|
|
56
|
-
"trap restore_echo EXIT HUP INT TERM",
|
|
57
|
-
"printf 'Re-enter the vault password to enable unattended restart: ' >&2",
|
|
58
|
-
"stty -echo",
|
|
59
|
-
"IFS= read -r vault_password",
|
|
60
|
-
"stty echo",
|
|
61
|
-
"trap - EXIT HUP INT TERM",
|
|
62
|
-
"printf '\\n' >&2",
|
|
63
|
-
"[ -n \"$vault_password\" ] || { echo 'A vault password is required for unattended restart.' >&2; exit 1; }",
|
|
64
|
-
"printf %s \"$vault_password\" | clauth serve install --pw-stdin",
|
|
65
|
-
"unset vault_password",
|
|
66
|
-
"rm -f -- \"$0\"",
|
|
67
|
-
].join("\n");
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
export function writeEnrollmentScript({ supabaseUrl, anonKey, enrollmentCode, label, target = "windows", appDir } = {}) {
|
|
71
|
-
if (!["windows", "linux"].includes(target)) throw new Error(`Unsupported enrollment target: ${target}`);
|
|
72
|
-
const outputDir = appDir || (process.platform === "win32"
|
|
73
|
-
? path.join(process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"), "clauth")
|
|
74
|
-
: path.join(os.homedir(), ".config", "clauth"));
|
|
75
|
-
fs.mkdirSync(outputDir, { recursive: true });
|
|
76
|
-
const scriptPath = path.join(outputDir, enrollmentScriptName(label, target));
|
|
77
|
-
const script = target === "linux"
|
|
78
|
-
? linuxScript({ supabaseUrl, anonKey, enrollmentCode })
|
|
79
|
-
: windowsScript({ supabaseUrl, anonKey, enrollmentCode });
|
|
80
|
-
fs.writeFileSync(scriptPath, `${script}\n`, { encoding: "utf8", mode: target === "linux" ? 0o700 : undefined });
|
|
81
|
-
return scriptPath;
|
|
82
|
-
}
|
package/cli/webdav-service.js
DELETED
|
@@ -1,339 +0,0 @@
|
|
|
1
|
-
// cli/webdav-service.js
|
|
2
|
-
// WebDAV child-process supervisor — rclone serve webdav
|
|
3
|
-
//
|
|
4
|
-
// ISOLATION: This module has zero coupling to existing fs_* tool handlers.
|
|
5
|
-
// Rollback: set webdav.enabled=false in the config block (or remove it)
|
|
6
|
-
// + delete this file + remove the two-line import in serve.js.
|
|
7
|
-
//
|
|
8
|
-
// Design: lazy-start. The rclone child spawns on the first fs_dav_setup call.
|
|
9
|
-
// Subsequent calls return the running child's status without restarting.
|
|
10
|
-
// On child crash: exponential-backoff restart loop (2s → 5s → 15s → 60s).
|
|
11
|
-
|
|
12
|
-
import { spawn, execSync } from "child_process";
|
|
13
|
-
import { writeFileSync, readFileSync, unlinkSync, mkdirSync, readdirSync } from "fs";
|
|
14
|
-
import { tmpdir, homedir } from "os";
|
|
15
|
-
import { join } from "path";
|
|
16
|
-
import crypto from "crypto";
|
|
17
|
-
import * as api from "./api.js";
|
|
18
|
-
import { deriveToken } from "./fingerprint.js";
|
|
19
|
-
|
|
20
|
-
const WEBDAV_PORT = 8620;
|
|
21
|
-
const WEBDAV_USER = "claude";
|
|
22
|
-
const CRED_NAME = "webdav-claude";
|
|
23
|
-
const RESTART_DELAYS = [2000, 5000, 15000, 60000];
|
|
24
|
-
|
|
25
|
-
const APPDATA = process.env.APPDATA || join(homedir(), "AppData", "Roaming");
|
|
26
|
-
const CONFIG_PATH = join(APPDATA, "clauth", "webdav-config.json");
|
|
27
|
-
|
|
28
|
-
const DEFAULT_CONFIG = {
|
|
29
|
-
enabled: true,
|
|
30
|
-
port: WEBDAV_PORT,
|
|
31
|
-
credential: CRED_NAME,
|
|
32
|
-
upstreams: [],
|
|
33
|
-
};
|
|
34
|
-
|
|
35
|
-
// ── Config persistence ──────────────────────────────────────────
|
|
36
|
-
|
|
37
|
-
export function loadConfig() {
|
|
38
|
-
try {
|
|
39
|
-
const raw = readFileSync(CONFIG_PATH, "utf8");
|
|
40
|
-
const cfg = JSON.parse(raw);
|
|
41
|
-
if (!Array.isArray(cfg.upstreams)) cfg.upstreams = [];
|
|
42
|
-
return { ...DEFAULT_CONFIG, ...cfg };
|
|
43
|
-
} catch {
|
|
44
|
-
return { ...DEFAULT_CONFIG };
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export function saveConfig(cfg) {
|
|
49
|
-
const dir = join(APPDATA, "clauth");
|
|
50
|
-
mkdirSync(dir, { recursive: true });
|
|
51
|
-
writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2), "utf8");
|
|
52
|
-
_fsCfgCache = null;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
export function addMount(name, mountPath) {
|
|
56
|
-
const cfg = loadConfig();
|
|
57
|
-
if (cfg.upstreams.some(u => u.name === name)) return { error: `Mount '${name}' already exists` };
|
|
58
|
-
cfg.upstreams.push({ name, path: mountPath });
|
|
59
|
-
saveConfig(cfg);
|
|
60
|
-
return { ok: true, upstreams: cfg.upstreams };
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
export function removeMount(name) {
|
|
64
|
-
const cfg = loadConfig();
|
|
65
|
-
const before = cfg.upstreams.length;
|
|
66
|
-
cfg.upstreams = cfg.upstreams.filter(u => u.name !== name);
|
|
67
|
-
if (cfg.upstreams.length === before) return { error: `Mount '${name}' not found` };
|
|
68
|
-
saveConfig(cfg);
|
|
69
|
-
return { ok: true, upstreams: cfg.upstreams };
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
export function updateMount(name, updates) {
|
|
73
|
-
const cfg = loadConfig();
|
|
74
|
-
const mount = cfg.upstreams.find(u => u.name === name);
|
|
75
|
-
if (!mount) return { error: `Mount '${name}' not found` };
|
|
76
|
-
if (updates.credential !== undefined) cfg.credential = updates.credential;
|
|
77
|
-
saveConfig(cfg);
|
|
78
|
-
return { ok: true, upstreams: cfg.upstreams };
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
let _fsCfgCache = null;
|
|
82
|
-
|
|
83
|
-
// ── Module state ────────────────────────────────────────────────
|
|
84
|
-
let _child = null;
|
|
85
|
-
let _configPath = null;
|
|
86
|
-
let _status = "stopped"; // stopped | starting | running | error
|
|
87
|
-
let _error = null;
|
|
88
|
-
let _restartCount = 0;
|
|
89
|
-
let _startedAt = null;
|
|
90
|
-
let _vaultFn = null; // () => { password, machineHash }
|
|
91
|
-
let _cfg = null;
|
|
92
|
-
|
|
93
|
-
// ── Public API ───────────────────────────────────────────────────
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* Ensure the rclone WebDAV child is running.
|
|
97
|
-
* Idempotent — safe to call multiple times.
|
|
98
|
-
* Returns { ok: true } or { error: string }.
|
|
99
|
-
*/
|
|
100
|
-
export async function ensureRunning(vaultFn, config) {
|
|
101
|
-
if (vaultFn) _vaultFn = vaultFn;
|
|
102
|
-
if (config) _cfg = config;
|
|
103
|
-
if (!_cfg) _cfg = loadConfig();
|
|
104
|
-
|
|
105
|
-
if (_status === "running" && _child) return { ok: true };
|
|
106
|
-
if (_status === "starting") return { ok: true, message: "still starting" };
|
|
107
|
-
|
|
108
|
-
return _startChild();
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/** Current supervisor status — safe to call at any time. */
|
|
112
|
-
export function getStatus() {
|
|
113
|
-
return {
|
|
114
|
-
status: _status,
|
|
115
|
-
port: WEBDAV_PORT,
|
|
116
|
-
url: _status === "running" ? `http://127.0.0.1:${WEBDAV_PORT}` : null,
|
|
117
|
-
error: _error || null,
|
|
118
|
-
restart_count: _restartCount,
|
|
119
|
-
started_at: _startedAt || null,
|
|
120
|
-
};
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/** Kill the child and clean up the temp config file. */
|
|
124
|
-
export function shutdown() {
|
|
125
|
-
if (_child) {
|
|
126
|
-
_child.removeAllListeners();
|
|
127
|
-
_child.kill("SIGTERM");
|
|
128
|
-
_child = null;
|
|
129
|
-
}
|
|
130
|
-
_cleanupConfig();
|
|
131
|
-
_status = "stopped";
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* rclone-compatible AES-CTR "obscure" — same algorithm as `rclone obscure`.
|
|
136
|
-
* Used by fs_dav_setup to embed a non-plaintext credential in the mount script.
|
|
137
|
-
* The result can be passed as RCLONE_CONFIG_xxx_PASS.
|
|
138
|
-
*/
|
|
139
|
-
export function rcloneObscure(password) {
|
|
140
|
-
// Key from rclone source: lib/obscure/obscure.go cryptKey
|
|
141
|
-
const key = Buffer.from([
|
|
142
|
-
0x9c, 0x93, 0x5b, 0x48, 0x73, 0x0a, 0x55, 0x4d,
|
|
143
|
-
0x6b, 0xfd, 0x7c, 0x63, 0xc8, 0x86, 0xd9, 0x2d,
|
|
144
|
-
0x02, 0x01, 0x99, 0x87, 0x54, 0xd3, 0x9b, 0xeb,
|
|
145
|
-
0x40, 0x87, 0x3f, 0xa1, 0x3f, 0x7a, 0xbe, 0x5e,
|
|
146
|
-
]);
|
|
147
|
-
const iv = crypto.randomBytes(16);
|
|
148
|
-
const cipher = crypto.createCipheriv("aes-256-ctr", key, iv);
|
|
149
|
-
const ct = Buffer.concat([cipher.update(Buffer.from(password, "utf8")), cipher.final()]);
|
|
150
|
-
return Buffer.concat([iv, ct]).toString("base64url");
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
// ── Internal ─────────────────────────────────────────────────────
|
|
154
|
-
|
|
155
|
-
async function _startChild() {
|
|
156
|
-
_status = "starting";
|
|
157
|
-
_error = null;
|
|
158
|
-
|
|
159
|
-
if (!_cfg?.upstreams?.length) {
|
|
160
|
-
_status = "error";
|
|
161
|
-
_error = "No mount points configured — add them in the clauth dashboard";
|
|
162
|
-
return { error: _error };
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
// 1. Resolve credential from vault
|
|
166
|
-
let pass;
|
|
167
|
-
try {
|
|
168
|
-
const vault = _vaultFn?.();
|
|
169
|
-
if (!vault?.password) {
|
|
170
|
-
_status = "error";
|
|
171
|
-
_error = "Vault is locked — unlock clauth first";
|
|
172
|
-
return { error: _error };
|
|
173
|
-
}
|
|
174
|
-
const { token, timestamp } = deriveToken(vault.password, vault.machineHash);
|
|
175
|
-
const credName = _cfg.credential || CRED_NAME;
|
|
176
|
-
const result = await api.retrieve(vault.password, vault.machineHash, token, timestamp, credName);
|
|
177
|
-
if (!result.value) {
|
|
178
|
-
_status = "error";
|
|
179
|
-
_error = `Credential '${credName}' not found — register it in the vault first`;
|
|
180
|
-
return { error: _error };
|
|
181
|
-
}
|
|
182
|
-
pass = result.value;
|
|
183
|
-
} catch (err) {
|
|
184
|
-
_status = "error";
|
|
185
|
-
_error = `Credential fetch failed: ${err.message}`;
|
|
186
|
-
return { error: _error };
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
// 2. Locate rclone
|
|
190
|
-
const rclonePath = _findRclone();
|
|
191
|
-
if (!rclonePath) {
|
|
192
|
-
_status = "error";
|
|
193
|
-
_error = "rclone not found — install with: winget install Rclone.Rclone";
|
|
194
|
-
return { error: _error };
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
// 3. Write rclone config — combine backend with quoted entries for paths with spaces.
|
|
198
|
-
// Per rclone docs: "dir=remote:path with space" (quote the whole entry).
|
|
199
|
-
const mounts = _cfg.upstreams || [];
|
|
200
|
-
const combineUpstreams = mounts
|
|
201
|
-
.map(u => `"${u.name}=${u.path.replace(/\\/g, "/")}"`)
|
|
202
|
-
.join(" ");
|
|
203
|
-
const configContent = `[srv]\ntype = combine\nupstreams = ${combineUpstreams}\n`;
|
|
204
|
-
_cleanupConfig();
|
|
205
|
-
_configPath = join(tmpdir(), `.rclone-webdav-${process.pid}.conf`);
|
|
206
|
-
try {
|
|
207
|
-
writeFileSync(_configPath, configContent, { mode: 0o600 });
|
|
208
|
-
} catch (err) {
|
|
209
|
-
_status = "error";
|
|
210
|
-
_error = `Config write failed: ${err.message}`;
|
|
211
|
-
return { error: _error };
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
// 4. Spawn rclone
|
|
215
|
-
return new Promise(resolve => {
|
|
216
|
-
let settled = false;
|
|
217
|
-
const settle = (val) => { if (!settled) { settled = true; resolve(val); } };
|
|
218
|
-
|
|
219
|
-
try {
|
|
220
|
-
_child = spawn(rclonePath, [
|
|
221
|
-
"serve", "webdav", "srv:",
|
|
222
|
-
"--config", _configPath,
|
|
223
|
-
"--addr", `127.0.0.1:${WEBDAV_PORT}`,
|
|
224
|
-
"--user", WEBDAV_USER,
|
|
225
|
-
"--pass", pass,
|
|
226
|
-
"--no-modtime",
|
|
227
|
-
"--vfs-cache-mode", "full",
|
|
228
|
-
"--vfs-write-back", "0s",
|
|
229
|
-
"--ignore-size",
|
|
230
|
-
"--cache-dir", join(process.env.TEMP || tmpdir(), "rclone-dav-cache"),
|
|
231
|
-
"--transfers", "4",
|
|
232
|
-
], { stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
|
|
233
|
-
|
|
234
|
-
} catch (spawnErr) {
|
|
235
|
-
_child = null;
|
|
236
|
-
_status = "error";
|
|
237
|
-
_error = spawnErr.message;
|
|
238
|
-
_cleanupConfig();
|
|
239
|
-
settle({ error: spawnErr.message });
|
|
240
|
-
return;
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
_child.stdout.on("data", () => {});
|
|
244
|
-
|
|
245
|
-
_child.stderr.on("data", (chunk) => {
|
|
246
|
-
const line = chunk.toString().trim();
|
|
247
|
-
if (line.includes("Serving WebDAV") || line.includes("Listening on")) {
|
|
248
|
-
_status = "running";
|
|
249
|
-
_startedAt = new Date().toISOString();
|
|
250
|
-
settle({ ok: true });
|
|
251
|
-
}
|
|
252
|
-
// Surface errors but don't stop on every stderr line — rclone logs normally to stderr
|
|
253
|
-
if (/\berror\b/i.test(line) || /\bfatal\b/i.test(line)) {
|
|
254
|
-
_error = line.slice(0, 300);
|
|
255
|
-
}
|
|
256
|
-
});
|
|
257
|
-
|
|
258
|
-
_child.on("error", (err) => {
|
|
259
|
-
_child = null;
|
|
260
|
-
_status = "error";
|
|
261
|
-
_error = err.message;
|
|
262
|
-
_cleanupConfig();
|
|
263
|
-
settle({ error: err.message });
|
|
264
|
-
_scheduleRestart();
|
|
265
|
-
});
|
|
266
|
-
|
|
267
|
-
_child.on("exit", (code, signal) => {
|
|
268
|
-
_child = null;
|
|
269
|
-
if (signal === "SIGTERM" || signal === "SIGKILL" || code === 0) {
|
|
270
|
-
_status = "stopped";
|
|
271
|
-
_cleanupConfig();
|
|
272
|
-
return;
|
|
273
|
-
}
|
|
274
|
-
_status = "error";
|
|
275
|
-
_error = `rclone exited unexpectedly (code=${code} signal=${signal})`;
|
|
276
|
-
_cleanupConfig();
|
|
277
|
-
settle({ error: _error });
|
|
278
|
-
_scheduleRestart();
|
|
279
|
-
});
|
|
280
|
-
|
|
281
|
-
// Optimistic fallback: if rclone doesn't print "Serving WebDAV" within 2s, assume running
|
|
282
|
-
setTimeout(() => {
|
|
283
|
-
if (_status === "starting") {
|
|
284
|
-
_status = "running";
|
|
285
|
-
_startedAt = _startedAt || new Date().toISOString();
|
|
286
|
-
}
|
|
287
|
-
settle({ ok: true });
|
|
288
|
-
}, 2000);
|
|
289
|
-
});
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
function _scheduleRestart() {
|
|
293
|
-
const delay = RESTART_DELAYS[Math.min(_restartCount, RESTART_DELAYS.length - 1)];
|
|
294
|
-
_restartCount++;
|
|
295
|
-
setTimeout(() => {
|
|
296
|
-
if (_status === "stopped") return; // intentionally stopped, don't restart
|
|
297
|
-
_startChild();
|
|
298
|
-
}, delay);
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
function _cleanupConfig() {
|
|
302
|
-
if (_configPath) {
|
|
303
|
-
try { unlinkSync(_configPath); } catch {}
|
|
304
|
-
_configPath = null;
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
function _findRclone() {
|
|
309
|
-
const home = homedir();
|
|
310
|
-
const wingetBase = process.env.LOCALAPPDATA
|
|
311
|
-
? join(process.env.LOCALAPPDATA, "Microsoft", "WinGet", "Packages")
|
|
312
|
-
: null;
|
|
313
|
-
const execOpts = { stdio: "pipe", timeout: 4000, windowsHide: true };
|
|
314
|
-
|
|
315
|
-
const candidates = [
|
|
316
|
-
"rclone",
|
|
317
|
-
"C:\\Program Files\\rclone\\rclone.exe",
|
|
318
|
-
join(home, "scoop", "apps", "rclone", "current", "rclone.exe"),
|
|
319
|
-
"C:\\ProgramData\\scoop\\apps\\rclone\\current\\rclone.exe",
|
|
320
|
-
];
|
|
321
|
-
|
|
322
|
-
// Winget installs into a version-stamped subdirectory — glob for it
|
|
323
|
-
if (wingetBase) {
|
|
324
|
-
const wingetPkg = join(wingetBase, "Rclone.Rclone_Microsoft.Winget.Source_8wekyb3d8bbwe");
|
|
325
|
-
try {
|
|
326
|
-
for (const d of readdirSync(wingetPkg)) {
|
|
327
|
-
if (d.startsWith("rclone-")) candidates.push(join(wingetPkg, d, "rclone.exe"));
|
|
328
|
-
}
|
|
329
|
-
} catch {}
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
for (const c of candidates) {
|
|
333
|
-
try {
|
|
334
|
-
execSync(`"${c}" version`, execOpts);
|
|
335
|
-
return c;
|
|
336
|
-
} catch {}
|
|
337
|
-
}
|
|
338
|
-
return null;
|
|
339
|
-
}
|