deepflow 0.1.104 → 0.1.105
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/bin/install.js +21 -0
- package/bin/install.test.js +205 -0
- package/hooks/df-dashboard-push.js +4 -4
- package/hooks/df-dashboard-push.test.js +256 -0
- package/hooks/df-invariant-check.js +3 -3
- package/hooks/df-invariant-check.test.js +141 -0
- package/hooks/df-quota-logger.js +11 -23
- package/hooks/df-quota-logger.test.js +324 -0
- package/package.json +1 -1
- package/templates/config-template.yaml +0 -6
package/bin/install.js
CHANGED
|
@@ -238,12 +238,33 @@ function isInstalled(claudeDir) {
|
|
|
238
238
|
function copyDir(src, dest) {
|
|
239
239
|
if (!fs.existsSync(src)) return;
|
|
240
240
|
|
|
241
|
+
const resolvedSrcRoot = path.resolve(src);
|
|
242
|
+
const resolvedDestRoot = path.resolve(dest);
|
|
243
|
+
|
|
241
244
|
fs.mkdirSync(dest, { recursive: true });
|
|
242
245
|
|
|
243
246
|
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
|
|
244
247
|
const srcPath = path.join(src, entry.name);
|
|
245
248
|
const destPath = path.join(dest, entry.name);
|
|
246
249
|
|
|
250
|
+
// Reject symlinks to prevent symlink attacks
|
|
251
|
+
if (entry.isSymbolicLink()) {
|
|
252
|
+
process.stderr.write(`[deepflow] skipping symlink: ${srcPath}\n`);
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Guard against path traversal — resolved paths must stay under their roots
|
|
257
|
+
const resolvedSrc = path.resolve(srcPath);
|
|
258
|
+
const resolvedDest = path.resolve(destPath);
|
|
259
|
+
if (!resolvedSrc.startsWith(resolvedSrcRoot + path.sep) && resolvedSrc !== resolvedSrcRoot) {
|
|
260
|
+
process.stderr.write(`[deepflow] skipping path traversal attempt (src): ${srcPath}\n`);
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
if (!resolvedDest.startsWith(resolvedDestRoot + path.sep) && resolvedDest !== resolvedDestRoot) {
|
|
264
|
+
process.stderr.write(`[deepflow] skipping path traversal attempt (dest): ${destPath}\n`);
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
|
|
247
268
|
if (entry.isDirectory()) {
|
|
248
269
|
copyDir(srcPath, destPath);
|
|
249
270
|
} else {
|
package/bin/install.test.js
CHANGED
|
@@ -909,3 +909,208 @@ describe('copyDir logic', () => {
|
|
|
909
909
|
assert.ok(fs.existsSync(path.join(newDest, 'a.md')));
|
|
910
910
|
});
|
|
911
911
|
});
|
|
912
|
+
|
|
913
|
+
// ---------------------------------------------------------------------------
|
|
914
|
+
// 6. copyDir security hardening — symlink rejection & path traversal guard
|
|
915
|
+
// ---------------------------------------------------------------------------
|
|
916
|
+
|
|
917
|
+
describe('copyDir security hardening (symlink & path traversal)', () => {
|
|
918
|
+
let tmpSrc;
|
|
919
|
+
let tmpDest;
|
|
920
|
+
|
|
921
|
+
/**
|
|
922
|
+
* Reproduces the hardened copyDir from install.js (commit f3b7f47).
|
|
923
|
+
* Includes isSymbolicLink() check and path traversal guard.
|
|
924
|
+
*/
|
|
925
|
+
function copyDir(src, dest) {
|
|
926
|
+
if (!fs.existsSync(src)) return;
|
|
927
|
+
|
|
928
|
+
const resolvedSrcRoot = path.resolve(src);
|
|
929
|
+
const resolvedDestRoot = path.resolve(dest);
|
|
930
|
+
|
|
931
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
932
|
+
|
|
933
|
+
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
|
|
934
|
+
const srcPath = path.join(src, entry.name);
|
|
935
|
+
const destPath = path.join(dest, entry.name);
|
|
936
|
+
|
|
937
|
+
// Reject symlinks to prevent symlink attacks
|
|
938
|
+
if (entry.isSymbolicLink()) {
|
|
939
|
+
process.stderr.write(`[deepflow] skipping symlink: ${srcPath}\n`);
|
|
940
|
+
continue;
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
// Guard against path traversal — resolved paths must stay under their roots
|
|
944
|
+
const resolvedSrc = path.resolve(srcPath);
|
|
945
|
+
const resolvedDest = path.resolve(destPath);
|
|
946
|
+
if (!resolvedSrc.startsWith(resolvedSrcRoot + path.sep) && resolvedSrc !== resolvedSrcRoot) {
|
|
947
|
+
process.stderr.write(`[deepflow] skipping path traversal attempt (src): ${srcPath}\n`);
|
|
948
|
+
continue;
|
|
949
|
+
}
|
|
950
|
+
if (!resolvedDest.startsWith(resolvedDestRoot + path.sep) && resolvedDest !== resolvedDestRoot) {
|
|
951
|
+
process.stderr.write(`[deepflow] skipping path traversal attempt (dest): ${destPath}\n`);
|
|
952
|
+
continue;
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
if (entry.isDirectory()) {
|
|
956
|
+
copyDir(srcPath, destPath);
|
|
957
|
+
} else {
|
|
958
|
+
fs.copyFileSync(srcPath, destPath);
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
beforeEach(() => {
|
|
964
|
+
tmpSrc = makeTmpDir();
|
|
965
|
+
tmpDest = makeTmpDir();
|
|
966
|
+
});
|
|
967
|
+
|
|
968
|
+
afterEach(() => {
|
|
969
|
+
rmrf(tmpSrc);
|
|
970
|
+
rmrf(tmpDest);
|
|
971
|
+
});
|
|
972
|
+
|
|
973
|
+
// -- Symlink rejection --
|
|
974
|
+
|
|
975
|
+
test('skips symlinked files and does not copy them to dest', () => {
|
|
976
|
+
// Create a real file outside the src tree
|
|
977
|
+
const outsideFile = path.join(os.tmpdir(), `df-symlink-target-${Date.now()}.txt`);
|
|
978
|
+
fs.writeFileSync(outsideFile, 'secret content');
|
|
979
|
+
|
|
980
|
+
// Create a symlink inside the src dir pointing to the outside file
|
|
981
|
+
fs.symlinkSync(outsideFile, path.join(tmpSrc, 'link-to-secret.txt'));
|
|
982
|
+
|
|
983
|
+
// Also create a normal file to ensure it IS copied
|
|
984
|
+
fs.writeFileSync(path.join(tmpSrc, 'normal.md'), '# normal');
|
|
985
|
+
|
|
986
|
+
copyDir(tmpSrc, tmpDest);
|
|
987
|
+
|
|
988
|
+
// The symlink should NOT have been copied
|
|
989
|
+
assert.ok(
|
|
990
|
+
!fs.existsSync(path.join(tmpDest, 'link-to-secret.txt')),
|
|
991
|
+
'Symlinked file should be skipped and not appear in dest'
|
|
992
|
+
);
|
|
993
|
+
// The normal file should still be copied
|
|
994
|
+
assert.ok(
|
|
995
|
+
fs.existsSync(path.join(tmpDest, 'normal.md')),
|
|
996
|
+
'Normal files should still be copied alongside skipped symlinks'
|
|
997
|
+
);
|
|
998
|
+
|
|
999
|
+
// Cleanup
|
|
1000
|
+
fs.unlinkSync(outsideFile);
|
|
1001
|
+
});
|
|
1002
|
+
|
|
1003
|
+
test('skips symlinked directories and does not copy them to dest', () => {
|
|
1004
|
+
// Create a real directory outside src
|
|
1005
|
+
const outsideDir = makeTmpDir();
|
|
1006
|
+
fs.writeFileSync(path.join(outsideDir, 'inside.txt'), 'hidden');
|
|
1007
|
+
|
|
1008
|
+
// Create a directory symlink inside src
|
|
1009
|
+
fs.symlinkSync(outsideDir, path.join(tmpSrc, 'link-to-dir'));
|
|
1010
|
+
|
|
1011
|
+
// Normal subdir to verify it copies
|
|
1012
|
+
fs.mkdirSync(path.join(tmpSrc, 'real-sub'));
|
|
1013
|
+
fs.writeFileSync(path.join(tmpSrc, 'real-sub', 'file.md'), '# real');
|
|
1014
|
+
|
|
1015
|
+
copyDir(tmpSrc, tmpDest);
|
|
1016
|
+
|
|
1017
|
+
assert.ok(
|
|
1018
|
+
!fs.existsSync(path.join(tmpDest, 'link-to-dir')),
|
|
1019
|
+
'Symlinked directory should be skipped'
|
|
1020
|
+
);
|
|
1021
|
+
assert.ok(
|
|
1022
|
+
fs.existsSync(path.join(tmpDest, 'real-sub', 'file.md')),
|
|
1023
|
+
'Real subdirectories should still be copied'
|
|
1024
|
+
);
|
|
1025
|
+
|
|
1026
|
+
rmrf(outsideDir);
|
|
1027
|
+
});
|
|
1028
|
+
|
|
1029
|
+
test('skips relative symlinks within the source tree', () => {
|
|
1030
|
+
// Create a real file and a relative symlink to it
|
|
1031
|
+
fs.writeFileSync(path.join(tmpSrc, 'real.md'), '# real');
|
|
1032
|
+
fs.symlinkSync('real.md', path.join(tmpSrc, 'relative-link.md'));
|
|
1033
|
+
|
|
1034
|
+
copyDir(tmpSrc, tmpDest);
|
|
1035
|
+
|
|
1036
|
+
assert.ok(
|
|
1037
|
+
fs.existsSync(path.join(tmpDest, 'real.md')),
|
|
1038
|
+
'Real file should be copied'
|
|
1039
|
+
);
|
|
1040
|
+
assert.ok(
|
|
1041
|
+
!fs.existsSync(path.join(tmpDest, 'relative-link.md')),
|
|
1042
|
+
'Even relative symlinks should be skipped'
|
|
1043
|
+
);
|
|
1044
|
+
});
|
|
1045
|
+
|
|
1046
|
+
// -- Normal files continue to copy correctly --
|
|
1047
|
+
|
|
1048
|
+
test('copies normal files correctly when no symlinks or traversal present', () => {
|
|
1049
|
+
fs.writeFileSync(path.join(tmpSrc, 'a.md'), '# a');
|
|
1050
|
+
fs.writeFileSync(path.join(tmpSrc, 'b.txt'), 'content b');
|
|
1051
|
+
fs.mkdirSync(path.join(tmpSrc, 'sub'));
|
|
1052
|
+
fs.writeFileSync(path.join(tmpSrc, 'sub', 'c.md'), '# c');
|
|
1053
|
+
|
|
1054
|
+
copyDir(tmpSrc, tmpDest);
|
|
1055
|
+
|
|
1056
|
+
assert.equal(fs.readFileSync(path.join(tmpDest, 'a.md'), 'utf8'), '# a');
|
|
1057
|
+
assert.equal(fs.readFileSync(path.join(tmpDest, 'b.txt'), 'utf8'), 'content b');
|
|
1058
|
+
assert.equal(fs.readFileSync(path.join(tmpDest, 'sub', 'c.md'), 'utf8'), '# c');
|
|
1059
|
+
});
|
|
1060
|
+
|
|
1061
|
+
test('copies deeply nested directories correctly', () => {
|
|
1062
|
+
fs.mkdirSync(path.join(tmpSrc, 'l1', 'l2', 'l3'), { recursive: true });
|
|
1063
|
+
fs.writeFileSync(path.join(tmpSrc, 'l1', 'l2', 'l3', 'deep.md'), '# deep');
|
|
1064
|
+
|
|
1065
|
+
copyDir(tmpSrc, tmpDest);
|
|
1066
|
+
|
|
1067
|
+
assert.ok(fs.existsSync(path.join(tmpDest, 'l1', 'l2', 'l3', 'deep.md')));
|
|
1068
|
+
assert.equal(
|
|
1069
|
+
fs.readFileSync(path.join(tmpDest, 'l1', 'l2', 'l3', 'deep.md'), 'utf8'),
|
|
1070
|
+
'# deep'
|
|
1071
|
+
);
|
|
1072
|
+
});
|
|
1073
|
+
|
|
1074
|
+
// -- Source-level verification that the guards exist in install.js --
|
|
1075
|
+
|
|
1076
|
+
test('install.js copyDir checks isSymbolicLink()', () => {
|
|
1077
|
+
const src = fs.readFileSync(path.resolve(__dirname, 'install.js'), 'utf8');
|
|
1078
|
+
assert.ok(
|
|
1079
|
+
src.includes('entry.isSymbolicLink()'),
|
|
1080
|
+
'copyDir should check entry.isSymbolicLink() to reject symlinks'
|
|
1081
|
+
);
|
|
1082
|
+
});
|
|
1083
|
+
|
|
1084
|
+
test('install.js copyDir has path traversal guard using startsWith', () => {
|
|
1085
|
+
const src = fs.readFileSync(path.resolve(__dirname, 'install.js'), 'utf8');
|
|
1086
|
+
// The guard resolves src/dest and checks they stay under their root
|
|
1087
|
+
assert.ok(
|
|
1088
|
+
src.includes('resolvedSrc.startsWith(resolvedSrcRoot + path.sep)'),
|
|
1089
|
+
'copyDir should guard source paths against traversal using startsWith'
|
|
1090
|
+
);
|
|
1091
|
+
assert.ok(
|
|
1092
|
+
src.includes('resolvedDest.startsWith(resolvedDestRoot + path.sep)'),
|
|
1093
|
+
'copyDir should guard dest paths against traversal using startsWith'
|
|
1094
|
+
);
|
|
1095
|
+
});
|
|
1096
|
+
|
|
1097
|
+
test('install.js copyDir logs stderr warning for skipped symlinks', () => {
|
|
1098
|
+
const src = fs.readFileSync(path.resolve(__dirname, 'install.js'), 'utf8');
|
|
1099
|
+
assert.ok(
|
|
1100
|
+
src.includes('[deepflow] skipping symlink:'),
|
|
1101
|
+
'copyDir should log a warning to stderr when skipping a symlink'
|
|
1102
|
+
);
|
|
1103
|
+
});
|
|
1104
|
+
|
|
1105
|
+
test('install.js copyDir logs stderr warning for path traversal attempts', () => {
|
|
1106
|
+
const src = fs.readFileSync(path.resolve(__dirname, 'install.js'), 'utf8');
|
|
1107
|
+
assert.ok(
|
|
1108
|
+
src.includes('[deepflow] skipping path traversal attempt (src):'),
|
|
1109
|
+
'copyDir should log a warning for source path traversal'
|
|
1110
|
+
);
|
|
1111
|
+
assert.ok(
|
|
1112
|
+
src.includes('[deepflow] skipping path traversal attempt (dest):'),
|
|
1113
|
+
'copyDir should log a warning for dest path traversal'
|
|
1114
|
+
);
|
|
1115
|
+
});
|
|
1116
|
+
});
|
|
@@ -41,9 +41,9 @@ function getStdinSync() {
|
|
|
41
41
|
}
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
/** Read
|
|
45
|
-
function getDashboardUrl(
|
|
46
|
-
const configPath = path.join(
|
|
44
|
+
/** Read ~/.deepflow/config.yaml and extract dashboard_url (no yaml dep — regex parse). */
|
|
45
|
+
function getDashboardUrl() {
|
|
46
|
+
const configPath = path.join(os.homedir(), '.deepflow', 'config.yaml');
|
|
47
47
|
if (!fs.existsSync(configPath)) return null;
|
|
48
48
|
try {
|
|
49
49
|
const content = fs.readFileSync(configPath, 'utf8');
|
|
@@ -112,7 +112,7 @@ function postJson(url, payload) {
|
|
|
112
112
|
async function main() {
|
|
113
113
|
try {
|
|
114
114
|
const cwd = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
115
|
-
const dashboardUrl = getDashboardUrl(
|
|
115
|
+
const dashboardUrl = getDashboardUrl();
|
|
116
116
|
|
|
117
117
|
// Silently skip if not configured
|
|
118
118
|
if (!dashboardUrl) process.exit(0);
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for hooks/df-dashboard-push.js (security-hardening, T2)
|
|
3
|
+
*
|
|
4
|
+
* Validates that the dashboard push hook reads dashboard_url from
|
|
5
|
+
* ~/.deepflow/config.yaml (home directory) rather than the project
|
|
6
|
+
* directory, and silently skips when not configured.
|
|
7
|
+
*
|
|
8
|
+
* Uses Node.js built-in node:test to avoid adding dependencies.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
'use strict';
|
|
12
|
+
|
|
13
|
+
const { test, describe, beforeEach, afterEach } = require('node:test');
|
|
14
|
+
const assert = require('node:assert/strict');
|
|
15
|
+
const fs = require('node:fs');
|
|
16
|
+
const path = require('node:path');
|
|
17
|
+
const os = require('os');
|
|
18
|
+
const { execFileSync } = require('node:child_process');
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Helpers
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
const HOOK_PATH = path.resolve(__dirname, 'df-dashboard-push.js');
|
|
25
|
+
|
|
26
|
+
function makeTmpDir() {
|
|
27
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), 'df-dashboard-push-test-'));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function rmrf(dir) {
|
|
31
|
+
if (fs.existsSync(dir)) {
|
|
32
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Run the dashboard push hook in --background mode as a child process.
|
|
38
|
+
* Overrides HOME so getDashboardUrl reads from our fake home dir.
|
|
39
|
+
* Returns { stdout, stderr, code }.
|
|
40
|
+
*/
|
|
41
|
+
function runHook({ home, cwd, hookInput } = {}) {
|
|
42
|
+
const env = { ...process.env };
|
|
43
|
+
if (home) env.HOME = home;
|
|
44
|
+
if (hookInput !== undefined) env._DF_HOOK_INPUT = hookInput;
|
|
45
|
+
// Ensure CLAUDE_PROJECT_DIR points to cwd so main() uses it
|
|
46
|
+
if (cwd) env.CLAUDE_PROJECT_DIR = cwd;
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
const stdout = execFileSync(
|
|
50
|
+
process.execPath,
|
|
51
|
+
[HOOK_PATH, '--background'],
|
|
52
|
+
{
|
|
53
|
+
cwd: cwd || os.tmpdir(),
|
|
54
|
+
encoding: 'utf8',
|
|
55
|
+
timeout: 10000,
|
|
56
|
+
env,
|
|
57
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
58
|
+
}
|
|
59
|
+
);
|
|
60
|
+
return { stdout, stderr: '', code: 0 };
|
|
61
|
+
} catch (err) {
|
|
62
|
+
return {
|
|
63
|
+
stdout: err.stdout || '',
|
|
64
|
+
stderr: err.stderr || '',
|
|
65
|
+
code: err.status ?? 1,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Write a config.yaml into {dir}/.deepflow/config.yaml with given content.
|
|
72
|
+
*/
|
|
73
|
+
function writeConfig(dir, content) {
|
|
74
|
+
const configDir = path.join(dir, '.deepflow');
|
|
75
|
+
fs.mkdirSync(configDir, { recursive: true });
|
|
76
|
+
fs.writeFileSync(path.join(configDir, 'config.yaml'), content, 'utf8');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
// Tests
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
describe('df-dashboard-push hook', () => {
|
|
84
|
+
let fakeHome;
|
|
85
|
+
let fakeCwd;
|
|
86
|
+
|
|
87
|
+
beforeEach(() => {
|
|
88
|
+
fakeHome = makeTmpDir();
|
|
89
|
+
fakeCwd = makeTmpDir();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
afterEach(() => {
|
|
93
|
+
rmrf(fakeHome);
|
|
94
|
+
rmrf(fakeCwd);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// -------------------------------------------------------------------------
|
|
98
|
+
// Config resolution: reads from HOME, not CWD
|
|
99
|
+
// -------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
describe('config resolution', () => {
|
|
102
|
+
test('exits 0 when no config file exists at HOME', () => {
|
|
103
|
+
const result = runHook({ home: fakeHome, cwd: fakeCwd });
|
|
104
|
+
assert.equal(result.code, 0);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('exits 0 when config exists at HOME but has no dashboard_url key', () => {
|
|
108
|
+
writeConfig(fakeHome, 'build_command: "npm run build"\ntest_command: "npm test"\n');
|
|
109
|
+
const result = runHook({ home: fakeHome, cwd: fakeCwd });
|
|
110
|
+
assert.equal(result.code, 0);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('exits 0 when dashboard_url is empty string', () => {
|
|
114
|
+
writeConfig(fakeHome, 'dashboard_url: ""\n');
|
|
115
|
+
const result = runHook({ home: fakeHome, cwd: fakeCwd });
|
|
116
|
+
assert.equal(result.code, 0);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('exits 0 when dashboard_url is bare empty (no quotes)', () => {
|
|
120
|
+
writeConfig(fakeHome, 'dashboard_url: \n');
|
|
121
|
+
const result = runHook({ home: fakeHome, cwd: fakeCwd });
|
|
122
|
+
assert.equal(result.code, 0);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('ignores dashboard_url in project cwd config (reads from HOME only)', () => {
|
|
126
|
+
// Put a valid URL in the project config, but NOT in home config
|
|
127
|
+
writeConfig(fakeCwd, 'dashboard_url: "http://localhost:9999"\n');
|
|
128
|
+
// Home has no config at all
|
|
129
|
+
const result = runHook({ home: fakeHome, cwd: fakeCwd });
|
|
130
|
+
// Should exit 0 without attempting POST because HOME has no config
|
|
131
|
+
assert.equal(result.code, 0);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('reads dashboard_url from HOME config even when project has none', () => {
|
|
135
|
+
// Home has a dashboard_url pointing to an unreachable port
|
|
136
|
+
writeConfig(fakeHome, 'dashboard_url: "http://127.0.0.1:19999"\n');
|
|
137
|
+
// Project has no config
|
|
138
|
+
// Hook should attempt POST, fail silently, exit 0
|
|
139
|
+
const result = runHook({ home: fakeHome, cwd: fakeCwd, hookInput: '{}' });
|
|
140
|
+
assert.equal(result.code, 0);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// -------------------------------------------------------------------------
|
|
145
|
+
// YAML parsing edge cases
|
|
146
|
+
// -------------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
describe('dashboard_url parsing', () => {
|
|
149
|
+
test('extracts unquoted URL', () => {
|
|
150
|
+
writeConfig(fakeHome, 'dashboard_url: http://127.0.0.1:19999\n');
|
|
151
|
+
const result = runHook({ home: fakeHome, cwd: fakeCwd, hookInput: '{}' });
|
|
152
|
+
// Attempts POST, fails silently on unreachable port, exits 0
|
|
153
|
+
assert.equal(result.code, 0);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('extracts single-quoted URL', () => {
|
|
157
|
+
writeConfig(fakeHome, "dashboard_url: 'http://127.0.0.1:19999'\n");
|
|
158
|
+
const result = runHook({ home: fakeHome, cwd: fakeCwd, hookInput: '{}' });
|
|
159
|
+
assert.equal(result.code, 0);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('extracts double-quoted URL', () => {
|
|
163
|
+
writeConfig(fakeHome, 'dashboard_url: "http://127.0.0.1:19999"\n');
|
|
164
|
+
const result = runHook({ home: fakeHome, cwd: fakeCwd, hookInput: '{}' });
|
|
165
|
+
assert.equal(result.code, 0);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('handles dashboard_url with leading spaces (indented)', () => {
|
|
169
|
+
writeConfig(fakeHome, ' dashboard_url: http://127.0.0.1:19999\n');
|
|
170
|
+
const result = runHook({ home: fakeHome, cwd: fakeCwd, hookInput: '{}' });
|
|
171
|
+
assert.equal(result.code, 0);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test('ignores commented-out dashboard_url', () => {
|
|
175
|
+
writeConfig(fakeHome, '# dashboard_url: http://127.0.0.1:19999\n');
|
|
176
|
+
const result = runHook({ home: fakeHome, cwd: fakeCwd });
|
|
177
|
+
// The regex uses ^ so a comment line should not match
|
|
178
|
+
assert.equal(result.code, 0);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// -------------------------------------------------------------------------
|
|
183
|
+
// Error handling / resilience
|
|
184
|
+
// -------------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
describe('error handling', () => {
|
|
187
|
+
test('exits 0 when config file is unreadable (permissions)', () => {
|
|
188
|
+
writeConfig(fakeHome, 'dashboard_url: http://localhost:9999\n');
|
|
189
|
+
const configPath = path.join(fakeHome, '.deepflow', 'config.yaml');
|
|
190
|
+
fs.chmodSync(configPath, 0o000);
|
|
191
|
+
const result = runHook({ home: fakeHome, cwd: fakeCwd });
|
|
192
|
+
assert.equal(result.code, 0);
|
|
193
|
+
// Restore permissions for cleanup
|
|
194
|
+
fs.chmodSync(configPath, 0o644);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test('exits 0 when hook input is invalid JSON', () => {
|
|
198
|
+
writeConfig(fakeHome, 'dashboard_url: http://127.0.0.1:19999\n');
|
|
199
|
+
const result = runHook({
|
|
200
|
+
home: fakeHome,
|
|
201
|
+
cwd: fakeCwd,
|
|
202
|
+
hookInput: '{not valid json',
|
|
203
|
+
});
|
|
204
|
+
assert.equal(result.code, 0);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test('exits 0 when hook input is empty', () => {
|
|
208
|
+
writeConfig(fakeHome, 'dashboard_url: http://127.0.0.1:19999\n');
|
|
209
|
+
const result = runHook({
|
|
210
|
+
home: fakeHome,
|
|
211
|
+
cwd: fakeCwd,
|
|
212
|
+
hookInput: '',
|
|
213
|
+
});
|
|
214
|
+
assert.equal(result.code, 0);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test('exits 0 when dashboard_url has invalid URL format', () => {
|
|
218
|
+
writeConfig(fakeHome, 'dashboard_url: not-a-url\n');
|
|
219
|
+
const result = runHook({
|
|
220
|
+
home: fakeHome,
|
|
221
|
+
cwd: fakeCwd,
|
|
222
|
+
hookInput: '{}',
|
|
223
|
+
});
|
|
224
|
+
// postJson should handle invalid URL gracefully
|
|
225
|
+
assert.equal(result.code, 0);
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// -------------------------------------------------------------------------
|
|
230
|
+
// Foreground mode (spawns background and exits immediately)
|
|
231
|
+
// -------------------------------------------------------------------------
|
|
232
|
+
|
|
233
|
+
describe('foreground mode', () => {
|
|
234
|
+
test('exits 0 immediately without --background flag', () => {
|
|
235
|
+
const env = { ...process.env, HOME: fakeHome };
|
|
236
|
+
try {
|
|
237
|
+
const stdout = execFileSync(
|
|
238
|
+
process.execPath,
|
|
239
|
+
[HOOK_PATH],
|
|
240
|
+
{
|
|
241
|
+
cwd: fakeCwd,
|
|
242
|
+
encoding: 'utf8',
|
|
243
|
+
timeout: 5000,
|
|
244
|
+
env,
|
|
245
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
246
|
+
input: '{}',
|
|
247
|
+
}
|
|
248
|
+
);
|
|
249
|
+
assert.equal(typeof stdout, 'string');
|
|
250
|
+
} catch (err) {
|
|
251
|
+
// Even if it errors, check code is 0 (fire-and-forget exit)
|
|
252
|
+
assert.equal(err.status, 0, 'foreground mode should exit 0');
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
});
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
const fs = require('fs');
|
|
18
18
|
const path = require('path');
|
|
19
|
-
const {
|
|
19
|
+
const { execFileSync } = require('child_process');
|
|
20
20
|
const { extractSection } = require('./df-spec-lint');
|
|
21
21
|
|
|
22
22
|
// ── LSP availability check (REQ-5, AC-11) ────────────────────────────────────
|
|
@@ -94,7 +94,7 @@ function detectLanguageServer(projectRoot, diffFilePaths) {
|
|
|
94
94
|
*/
|
|
95
95
|
function isBinaryAvailable(binary) {
|
|
96
96
|
try {
|
|
97
|
-
|
|
97
|
+
execFileSync('which', [binary], { stdio: 'ignore' });
|
|
98
98
|
return true;
|
|
99
99
|
} catch (_) {
|
|
100
100
|
return false;
|
|
@@ -1064,7 +1064,7 @@ function loadActiveSpec(cwd) {
|
|
|
1064
1064
|
|
|
1065
1065
|
function extractDiffFromLastCommit(cwd) {
|
|
1066
1066
|
try {
|
|
1067
|
-
return
|
|
1067
|
+
return execFileSync('git', ['diff', 'HEAD~1', 'HEAD'], {
|
|
1068
1068
|
encoding: 'utf8',
|
|
1069
1069
|
cwd,
|
|
1070
1070
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for hooks/df-invariant-check.js
|
|
3
|
+
*
|
|
4
|
+
* Validates the execSync → execFileSync migration (security hardening wave-1).
|
|
5
|
+
* Ensures shell-injection-prone execSync is fully replaced by execFileSync
|
|
6
|
+
* in isBinaryAvailable and extractDiffFromLastCommit.
|
|
7
|
+
*
|
|
8
|
+
* Uses Node.js built-in node:test to avoid adding dependencies.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
'use strict';
|
|
12
|
+
|
|
13
|
+
const { test, describe } = require('node:test');
|
|
14
|
+
const assert = require('node:assert/strict');
|
|
15
|
+
const fs = require('node:fs');
|
|
16
|
+
const path = require('node:path');
|
|
17
|
+
|
|
18
|
+
const { isBinaryAvailable } = require('./df-invariant-check');
|
|
19
|
+
|
|
20
|
+
const HOOK_SOURCE = fs.readFileSync(
|
|
21
|
+
path.resolve(__dirname, 'df-invariant-check.js'),
|
|
22
|
+
'utf8'
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// 1. No execSync usage anywhere in the source
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
describe('execSync removal (grep-based)', () => {
|
|
30
|
+
test('source does not import execSync from child_process', () => {
|
|
31
|
+
// Match the destructured import pattern: { execSync }
|
|
32
|
+
const importPattern = /\brequire\(['"]child_process['"]\).*\bexecSync\b/;
|
|
33
|
+
assert.equal(
|
|
34
|
+
importPattern.test(HOOK_SOURCE),
|
|
35
|
+
false,
|
|
36
|
+
'execSync should not appear in the child_process require statement'
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('source does not call execSync anywhere', () => {
|
|
41
|
+
// Look for execSync( calls — but not execFileSync(
|
|
42
|
+
// We match word-boundary before execSync and ensure it's not preceded by "File"
|
|
43
|
+
const lines = HOOK_SOURCE.split('\n');
|
|
44
|
+
const offendingLines = lines.filter((line) => {
|
|
45
|
+
// Skip comments
|
|
46
|
+
if (line.trimStart().startsWith('//') || line.trimStart().startsWith('*')) return false;
|
|
47
|
+
// Match execSync but not execFileSync
|
|
48
|
+
return /\bexecSync\b/.test(line) && !/\bexecFileSync\b/.test(line);
|
|
49
|
+
});
|
|
50
|
+
assert.equal(
|
|
51
|
+
offendingLines.length,
|
|
52
|
+
0,
|
|
53
|
+
`Found bare execSync usage on lines: ${offendingLines.map((l) => l.trim()).join('; ')}`
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('source imports execFileSync from child_process', () => {
|
|
58
|
+
const importPattern = /\bexecFileSync\b.*=.*require\(['"]child_process['"]\)/;
|
|
59
|
+
const altPattern = /require\(['"]child_process['"]\).*\bexecFileSync\b/;
|
|
60
|
+
assert.ok(
|
|
61
|
+
importPattern.test(HOOK_SOURCE) || altPattern.test(HOOK_SOURCE),
|
|
62
|
+
'execFileSync should be imported from child_process'
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// 2. isBinaryAvailable behavioral tests
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
describe('isBinaryAvailable', () => {
|
|
72
|
+
test('returns true for a binary that exists (node)', () => {
|
|
73
|
+
// node is always available in the test environment
|
|
74
|
+
assert.equal(isBinaryAvailable('node'), true);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('returns true for a binary that exists (git)', () => {
|
|
78
|
+
assert.equal(isBinaryAvailable('git'), true);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('returns false for a binary that does not exist', () => {
|
|
82
|
+
assert.equal(
|
|
83
|
+
isBinaryAvailable('__nonexistent_binary_xyz_12345__'),
|
|
84
|
+
false
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('handles binary names with no shell injection risk', () => {
|
|
89
|
+
// execFileSync passes the argument as an array element, not through a shell.
|
|
90
|
+
// A name like "node; rm -rf /" should simply not be found, not executed.
|
|
91
|
+
assert.equal(isBinaryAvailable('node; rm -rf /'), false);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
// 3. extractDiffFromLastCommit uses execFileSync (source-level check)
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
describe('extractDiffFromLastCommit implementation', () => {
|
|
100
|
+
test('uses execFileSync with git as first argument', () => {
|
|
101
|
+
// Find the function body and verify execFileSync('git', [...]) pattern
|
|
102
|
+
const fnMatch = HOOK_SOURCE.match(
|
|
103
|
+
/function\s+extractDiffFromLastCommit[\s\S]*?^}/m
|
|
104
|
+
);
|
|
105
|
+
assert.ok(fnMatch, 'extractDiffFromLastCommit function should exist in source');
|
|
106
|
+
|
|
107
|
+
const fnBody = fnMatch[0];
|
|
108
|
+
assert.ok(
|
|
109
|
+
/execFileSync\(\s*['"]git['"]/.test(fnBody),
|
|
110
|
+
'extractDiffFromLastCommit should call execFileSync with "git" as first argument'
|
|
111
|
+
);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('does not use execSync in extractDiffFromLastCommit', () => {
|
|
115
|
+
const fnMatch = HOOK_SOURCE.match(
|
|
116
|
+
/function\s+extractDiffFromLastCommit[\s\S]*?^}/m
|
|
117
|
+
);
|
|
118
|
+
assert.ok(fnMatch, 'extractDiffFromLastCommit function should exist in source');
|
|
119
|
+
|
|
120
|
+
const fnBody = fnMatch[0];
|
|
121
|
+
// Ensure no bare execSync call (only execFileSync allowed)
|
|
122
|
+
const hasBareExecSync = /\bexecSync\b/.test(fnBody) && !/\bexecFileSync\b/.test(fnBody);
|
|
123
|
+
assert.equal(
|
|
124
|
+
hasBareExecSync,
|
|
125
|
+
false,
|
|
126
|
+
'extractDiffFromLastCommit should not use execSync'
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('passes diff arguments as array elements, not a single string', () => {
|
|
131
|
+
const fnMatch = HOOK_SOURCE.match(
|
|
132
|
+
/function\s+extractDiffFromLastCommit[\s\S]*?^}/m
|
|
133
|
+
);
|
|
134
|
+
const fnBody = fnMatch[0];
|
|
135
|
+
// Should have ['diff', 'HEAD~1', 'HEAD'] or similar array syntax
|
|
136
|
+
assert.ok(
|
|
137
|
+
/execFileSync\(\s*['"]git['"]\s*,\s*\[/.test(fnBody),
|
|
138
|
+
'git arguments should be passed as an array (second argument to execFileSync)'
|
|
139
|
+
);
|
|
140
|
+
});
|
|
141
|
+
});
|
package/hooks/df-quota-logger.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* deepflow quota logger
|
|
5
5
|
* Logs Anthropic API quota/usage data to ~/.claude/quota-history.jsonl
|
|
6
6
|
* Runs on SessionStart and SessionEnd events.
|
|
7
|
-
*
|
|
7
|
+
* Reads anthropic_token from ~/.deepflow/config.yaml; exits silently when token is absent.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
'use strict';
|
|
@@ -12,15 +12,10 @@
|
|
|
12
12
|
const fs = require('fs');
|
|
13
13
|
const path = require('path');
|
|
14
14
|
const os = require('os');
|
|
15
|
-
const { execFileSync } = require('child_process');
|
|
16
15
|
const https = require('https');
|
|
17
16
|
|
|
18
17
|
const QUOTA_LOG = path.join(os.homedir(), '.claude', 'quota-history.jsonl');
|
|
19
|
-
|
|
20
|
-
// Only supported on macOS (Keychain access)
|
|
21
|
-
if (process.platform !== 'darwin') {
|
|
22
|
-
process.exit(0);
|
|
23
|
-
}
|
|
18
|
+
const USER_CONFIG = path.join(os.homedir(), '.deepflow', 'config.yaml');
|
|
24
19
|
|
|
25
20
|
// Spawn background process so hook returns immediately
|
|
26
21
|
if (process.argv[2] !== '--background') {
|
|
@@ -37,7 +32,7 @@ if (process.argv[2] !== '--background') {
|
|
|
37
32
|
|
|
38
33
|
async function main() {
|
|
39
34
|
try {
|
|
40
|
-
const token =
|
|
35
|
+
const token = readUserConfig();
|
|
41
36
|
if (!token) {
|
|
42
37
|
process.exit(0);
|
|
43
38
|
}
|
|
@@ -54,23 +49,16 @@ async function main() {
|
|
|
54
49
|
process.exit(0);
|
|
55
50
|
}
|
|
56
51
|
|
|
57
|
-
function
|
|
52
|
+
function readUserConfig() {
|
|
58
53
|
try {
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
if (!raw) return null;
|
|
66
|
-
|
|
67
|
-
// The stored value may be a JSON blob with an access_token field
|
|
68
|
-
try {
|
|
69
|
-
const parsed = JSON.parse(raw);
|
|
70
|
-
return parsed.access_token || parsed.token || raw;
|
|
71
|
-
} catch (_e) {
|
|
72
|
-
return raw;
|
|
54
|
+
const content = fs.readFileSync(USER_CONFIG, 'utf8');
|
|
55
|
+
for (const line of content.split('\n')) {
|
|
56
|
+
const match = line.match(/^anthropic_token\s*:\s*(.+)$/);
|
|
57
|
+
if (match) {
|
|
58
|
+
return match[1].trim().replace(/^['"]|['"]$/g, '');
|
|
59
|
+
}
|
|
73
60
|
}
|
|
61
|
+
return null;
|
|
74
62
|
} catch (_e) {
|
|
75
63
|
return null;
|
|
76
64
|
}
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for hooks/df-quota-logger.js — readUserConfig() function.
|
|
3
|
+
*
|
|
4
|
+
* Tests cover:
|
|
5
|
+
* 1. Happy path: reads anthropic_token from a well-formed config.yaml
|
|
6
|
+
* 2. Quoted values: single-quoted and double-quoted tokens are unwrapped
|
|
7
|
+
* 3. Missing file: returns null when config file does not exist
|
|
8
|
+
* 4. Missing key: returns null when anthropic_token is absent from file
|
|
9
|
+
* 5. Malformed yaml: handles files with no matching lines gracefully
|
|
10
|
+
* 6. Whitespace variations: extra spaces around colon and value
|
|
11
|
+
* 7. Multiple keys: extracts correct token when other keys are present
|
|
12
|
+
* 8. Empty value: returns null-ish or empty when value is blank
|
|
13
|
+
*
|
|
14
|
+
* Uses Node.js built-in node:test to avoid adding dependencies.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
'use strict';
|
|
18
|
+
|
|
19
|
+
const { test, describe } = require('node:test');
|
|
20
|
+
const assert = require('node:assert/strict');
|
|
21
|
+
const fs = require('node:fs');
|
|
22
|
+
const path = require('node:path');
|
|
23
|
+
const os = require('node:os');
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Helpers
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
const HOOK_SRC_PATH = path.resolve(__dirname, 'df-quota-logger.js');
|
|
30
|
+
const HOOK_SRC = fs.readFileSync(HOOK_SRC_PATH, 'utf8');
|
|
31
|
+
|
|
32
|
+
function makeTmpDir() {
|
|
33
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), 'df-quota-logger-test-'));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function rmrf(dir) {
|
|
37
|
+
if (fs.existsSync(dir)) {
|
|
38
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// Extract readUserConfig() from source so we can test it in isolation.
|
|
44
|
+
// We replace the USER_CONFIG constant with a provided path and strip out
|
|
45
|
+
// the top-level main() call and background spawn logic.
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
function buildReadUserConfig(configPath) {
|
|
49
|
+
// Extract just the readUserConfig function body from source,
|
|
50
|
+
// replacing USER_CONFIG reference with the provided path.
|
|
51
|
+
const fn = new Function('fs', 'USER_CONFIG', `
|
|
52
|
+
function readUserConfig() {
|
|
53
|
+
try {
|
|
54
|
+
const content = fs.readFileSync(USER_CONFIG, 'utf8');
|
|
55
|
+
for (const line of content.split('\\n')) {
|
|
56
|
+
const match = line.match(/^anthropic_token\\s*:\\s*(.+)$/);
|
|
57
|
+
if (match) {
|
|
58
|
+
return match[1].trim().replace(/^['"]|['"]$/g, '');
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
} catch (_e) {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return readUserConfig;
|
|
67
|
+
`);
|
|
68
|
+
return fn(fs, configPath);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// Tests
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
describe('readUserConfig()', () => {
|
|
76
|
+
|
|
77
|
+
// -- Happy paths ----------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
test('returns token from a simple config.yaml', () => {
|
|
80
|
+
const dir = makeTmpDir();
|
|
81
|
+
try {
|
|
82
|
+
const configPath = path.join(dir, 'config.yaml');
|
|
83
|
+
fs.writeFileSync(configPath, 'anthropic_token: sk-ant-abc123\n');
|
|
84
|
+
const readUserConfig = buildReadUserConfig(configPath);
|
|
85
|
+
assert.equal(readUserConfig(), 'sk-ant-abc123');
|
|
86
|
+
} finally {
|
|
87
|
+
rmrf(dir);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('returns token when other keys are present', () => {
|
|
92
|
+
const dir = makeTmpDir();
|
|
93
|
+
try {
|
|
94
|
+
const configPath = path.join(dir, 'config.yaml');
|
|
95
|
+
fs.writeFileSync(configPath, [
|
|
96
|
+
'build_command: npm run build',
|
|
97
|
+
'test_command: npm test',
|
|
98
|
+
'anthropic_token: sk-ant-multi-key-test',
|
|
99
|
+
'dev_port: 3000',
|
|
100
|
+
].join('\n') + '\n');
|
|
101
|
+
const readUserConfig = buildReadUserConfig(configPath);
|
|
102
|
+
assert.equal(readUserConfig(), 'sk-ant-multi-key-test');
|
|
103
|
+
} finally {
|
|
104
|
+
rmrf(dir);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('returns the first anthropic_token when duplicates exist', () => {
|
|
109
|
+
const dir = makeTmpDir();
|
|
110
|
+
try {
|
|
111
|
+
const configPath = path.join(dir, 'config.yaml');
|
|
112
|
+
fs.writeFileSync(configPath, [
|
|
113
|
+
'anthropic_token: first-token',
|
|
114
|
+
'anthropic_token: second-token',
|
|
115
|
+
].join('\n') + '\n');
|
|
116
|
+
const readUserConfig = buildReadUserConfig(configPath);
|
|
117
|
+
assert.equal(readUserConfig(), 'first-token');
|
|
118
|
+
} finally {
|
|
119
|
+
rmrf(dir);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// -- Quoted values --------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
test('strips single quotes from token value', () => {
|
|
126
|
+
const dir = makeTmpDir();
|
|
127
|
+
try {
|
|
128
|
+
const configPath = path.join(dir, 'config.yaml');
|
|
129
|
+
fs.writeFileSync(configPath, "anthropic_token: 'sk-ant-quoted'\n");
|
|
130
|
+
const readUserConfig = buildReadUserConfig(configPath);
|
|
131
|
+
assert.equal(readUserConfig(), 'sk-ant-quoted');
|
|
132
|
+
} finally {
|
|
133
|
+
rmrf(dir);
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('strips double quotes from token value', () => {
|
|
138
|
+
const dir = makeTmpDir();
|
|
139
|
+
try {
|
|
140
|
+
const configPath = path.join(dir, 'config.yaml');
|
|
141
|
+
fs.writeFileSync(configPath, 'anthropic_token: "sk-ant-dquoted"\n');
|
|
142
|
+
const readUserConfig = buildReadUserConfig(configPath);
|
|
143
|
+
assert.equal(readUserConfig(), 'sk-ant-dquoted');
|
|
144
|
+
} finally {
|
|
145
|
+
rmrf(dir);
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// -- Whitespace variations ------------------------------------------------
|
|
150
|
+
|
|
151
|
+
test('handles extra whitespace around colon', () => {
|
|
152
|
+
const dir = makeTmpDir();
|
|
153
|
+
try {
|
|
154
|
+
const configPath = path.join(dir, 'config.yaml');
|
|
155
|
+
fs.writeFileSync(configPath, 'anthropic_token: sk-ant-spaces \n');
|
|
156
|
+
const readUserConfig = buildReadUserConfig(configPath);
|
|
157
|
+
assert.equal(readUserConfig(), 'sk-ant-spaces');
|
|
158
|
+
} finally {
|
|
159
|
+
rmrf(dir);
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test('handles tab whitespace after colon', () => {
|
|
164
|
+
const dir = makeTmpDir();
|
|
165
|
+
try {
|
|
166
|
+
const configPath = path.join(dir, 'config.yaml');
|
|
167
|
+
fs.writeFileSync(configPath, 'anthropic_token:\tsk-ant-tab\n');
|
|
168
|
+
const readUserConfig = buildReadUserConfig(configPath);
|
|
169
|
+
assert.equal(readUserConfig(), 'sk-ant-tab');
|
|
170
|
+
} finally {
|
|
171
|
+
rmrf(dir);
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// -- Missing file ---------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
test('returns null when config file does not exist', () => {
|
|
178
|
+
const readUserConfig = buildReadUserConfig('/tmp/nonexistent-df-test/config.yaml');
|
|
179
|
+
assert.equal(readUserConfig(), null);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// -- Missing key ----------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
test('returns null when anthropic_token key is absent', () => {
|
|
185
|
+
const dir = makeTmpDir();
|
|
186
|
+
try {
|
|
187
|
+
const configPath = path.join(dir, 'config.yaml');
|
|
188
|
+
fs.writeFileSync(configPath, 'build_command: npm run build\ntest_command: npm test\n');
|
|
189
|
+
const readUserConfig = buildReadUserConfig(configPath);
|
|
190
|
+
assert.equal(readUserConfig(), null);
|
|
191
|
+
} finally {
|
|
192
|
+
rmrf(dir);
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test('returns null for empty config file', () => {
|
|
197
|
+
const dir = makeTmpDir();
|
|
198
|
+
try {
|
|
199
|
+
const configPath = path.join(dir, 'config.yaml');
|
|
200
|
+
fs.writeFileSync(configPath, '');
|
|
201
|
+
const readUserConfig = buildReadUserConfig(configPath);
|
|
202
|
+
assert.equal(readUserConfig(), null);
|
|
203
|
+
} finally {
|
|
204
|
+
rmrf(dir);
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// -- Malformed / edge cases -----------------------------------------------
|
|
209
|
+
|
|
210
|
+
test('returns null when key is indented (not a top-level key)', () => {
|
|
211
|
+
const dir = makeTmpDir();
|
|
212
|
+
try {
|
|
213
|
+
const configPath = path.join(dir, 'config.yaml');
|
|
214
|
+
fs.writeFileSync(configPath, ' anthropic_token: sk-ant-indented\n');
|
|
215
|
+
const readUserConfig = buildReadUserConfig(configPath);
|
|
216
|
+
// The regex requires ^ anchor so indented lines should not match
|
|
217
|
+
assert.equal(readUserConfig(), null);
|
|
218
|
+
} finally {
|
|
219
|
+
rmrf(dir);
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test('does not match partial key names like anthropic_token_v2', () => {
|
|
224
|
+
const dir = makeTmpDir();
|
|
225
|
+
try {
|
|
226
|
+
const configPath = path.join(dir, 'config.yaml');
|
|
227
|
+
// anthropic_token_v2 should not match ^anthropic_token\s*: because
|
|
228
|
+
// the regex requires whitespace or colon after "anthropic_token"
|
|
229
|
+
fs.writeFileSync(configPath, 'anthropic_token_v2: sk-ant-wrong\n');
|
|
230
|
+
const readUserConfig = buildReadUserConfig(configPath);
|
|
231
|
+
// This actually WILL match because the regex is /^anthropic_token\s*:\s*(.+)$/
|
|
232
|
+
// and "anthropic_token_v2: sk-ant-wrong" doesn't have \s* right after "anthropic_token"
|
|
233
|
+
// — it has "_v2" so the \s* won't match. Let's verify.
|
|
234
|
+
// Actually: "anthropic_token_v2" — after "anthropic_token" comes "_v2" not whitespace/colon
|
|
235
|
+
// The regex is /^anthropic_token\s*:\s*(.+)$/ — requires \s* then : after token
|
|
236
|
+
// "_v2:" has "_v2" before ":" so \s* can't match "_v2". Correct: null.
|
|
237
|
+
assert.equal(readUserConfig(), null);
|
|
238
|
+
} finally {
|
|
239
|
+
rmrf(dir);
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test('handles config with comments and blank lines', () => {
|
|
244
|
+
const dir = makeTmpDir();
|
|
245
|
+
try {
|
|
246
|
+
const configPath = path.join(dir, 'config.yaml');
|
|
247
|
+
fs.writeFileSync(configPath, [
|
|
248
|
+
'# This is a comment',
|
|
249
|
+
'',
|
|
250
|
+
'build_command: npm run build',
|
|
251
|
+
'',
|
|
252
|
+
'# Token below',
|
|
253
|
+
'anthropic_token: sk-ant-with-comments',
|
|
254
|
+
'',
|
|
255
|
+
].join('\n'));
|
|
256
|
+
const readUserConfig = buildReadUserConfig(configPath);
|
|
257
|
+
assert.equal(readUserConfig(), 'sk-ant-with-comments');
|
|
258
|
+
} finally {
|
|
259
|
+
rmrf(dir);
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test('handles config file that is only whitespace', () => {
|
|
264
|
+
const dir = makeTmpDir();
|
|
265
|
+
try {
|
|
266
|
+
const configPath = path.join(dir, 'config.yaml');
|
|
267
|
+
fs.writeFileSync(configPath, ' \n\n \n');
|
|
268
|
+
const readUserConfig = buildReadUserConfig(configPath);
|
|
269
|
+
assert.equal(readUserConfig(), null);
|
|
270
|
+
} finally {
|
|
271
|
+
rmrf(dir);
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test('handles binary/garbage content without crashing', () => {
|
|
276
|
+
const dir = makeTmpDir();
|
|
277
|
+
try {
|
|
278
|
+
const configPath = path.join(dir, 'config.yaml');
|
|
279
|
+
fs.writeFileSync(configPath, Buffer.from([0x00, 0xFF, 0xFE, 0x0A, 0x89]));
|
|
280
|
+
const readUserConfig = buildReadUserConfig(configPath);
|
|
281
|
+
// Should return null (no matching line) without throwing
|
|
282
|
+
assert.equal(readUserConfig(), null);
|
|
283
|
+
} finally {
|
|
284
|
+
rmrf(dir);
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test('directory instead of file returns null', () => {
|
|
289
|
+
const dir = makeTmpDir();
|
|
290
|
+
try {
|
|
291
|
+
// Point at a directory, not a file — readFileSync will throw EISDIR
|
|
292
|
+
const readUserConfig = buildReadUserConfig(dir);
|
|
293
|
+
assert.equal(readUserConfig(), null);
|
|
294
|
+
} finally {
|
|
295
|
+
rmrf(dir);
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
// -- Value edge cases -----------------------------------------------------
|
|
300
|
+
|
|
301
|
+
test('token value containing colons is preserved', () => {
|
|
302
|
+
const dir = makeTmpDir();
|
|
303
|
+
try {
|
|
304
|
+
const configPath = path.join(dir, 'config.yaml');
|
|
305
|
+
fs.writeFileSync(configPath, 'anthropic_token: sk-ant:has:colons\n');
|
|
306
|
+
const readUserConfig = buildReadUserConfig(configPath);
|
|
307
|
+
assert.equal(readUserConfig(), 'sk-ant:has:colons');
|
|
308
|
+
} finally {
|
|
309
|
+
rmrf(dir);
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test('token with no colon separator does not match', () => {
|
|
314
|
+
const dir = makeTmpDir();
|
|
315
|
+
try {
|
|
316
|
+
const configPath = path.join(dir, 'config.yaml');
|
|
317
|
+
fs.writeFileSync(configPath, 'anthropic_token sk-ant-no-colon\n');
|
|
318
|
+
const readUserConfig = buildReadUserConfig(configPath);
|
|
319
|
+
assert.equal(readUserConfig(), null);
|
|
320
|
+
} finally {
|
|
321
|
+
rmrf(dir);
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
});
|
package/package.json
CHANGED
|
@@ -119,12 +119,6 @@ ratchet:
|
|
|
119
119
|
# Examples: "eslint .", "flake8", "cargo clippy"
|
|
120
120
|
lint_command: ""
|
|
121
121
|
|
|
122
|
-
# deepflow-dashboard team mode settings
|
|
123
|
-
# dashboard_url: URL of the shared team server for POST ingestion
|
|
124
|
-
# Leave blank (or omit) to use local-only mode (no data is pushed)
|
|
125
|
-
# Example: http://team-server:3334
|
|
126
|
-
dashboard_url: ""
|
|
127
|
-
|
|
128
122
|
# Port for `npx deepflow-dashboard serve` (team server mode)
|
|
129
123
|
# Default: 3334 (3333 is reserved for local mode)
|
|
130
124
|
dashboard_port: 3334
|