@stravica-ai/digital-operator-pa 0.1.36 → 0.1.37
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/dist/engineering-pa-cli/src/pa/cli.mjs +9 -6
- package/dist/engineering-pa-cli/src/secrets/vault.mjs +198 -15
- package/dist/engineering-pa-library-cli/src/tier-map.mjs +101 -0
- package/dist/engineering-pa-library-cli/src/verbs/put.mjs +23 -2
- package/dist/engineering-pa-workspace/src/api.mjs +41 -30
- package/dist/engineering-pa-workspace/src/guards.mjs +161 -0
- package/dist/engineering-pa-workspace/src/server.mjs +109 -4
- package/dist/engineering-pa-workspace/src/ws.mjs +20 -8
- package/package.json +1 -1
|
@@ -326,12 +326,15 @@ Exit codes (tooling standard §6):
|
|
|
326
326
|
const WORKSPACE_USAGE = `pa workspace — workspace server lifecycle
|
|
327
327
|
|
|
328
328
|
Usage:
|
|
329
|
-
pa workspace start [--port N] [--no-open] Start the workspace server
|
|
330
|
-
pa workspace stop
|
|
331
|
-
pa workspace restart [--port N] [--no-open] Restart (supervisor-aware: launchctl kickstart / systemctl restart)
|
|
332
|
-
pa workspace status
|
|
333
|
-
pa workspace open
|
|
334
|
-
pa workspace -h, --help
|
|
329
|
+
pa workspace start [--port N] [--host H] [--no-open] Start the workspace server
|
|
330
|
+
pa workspace stop Stop the running server (refuses if supervised)
|
|
331
|
+
pa workspace restart [--port N] [--host H] [--no-open] Restart (supervisor-aware: launchctl kickstart / systemctl restart)
|
|
332
|
+
pa workspace status Show server status (supervisor-aware)
|
|
333
|
+
pa workspace open Open the workspace URL in a browser
|
|
334
|
+
pa workspace -h, --help Show this help
|
|
335
|
+
|
|
336
|
+
Bind host defaults to loopback (127.0.0.1). Use --host <addr> (or the
|
|
337
|
+
WORKSPACE_HOST env var) to expose the workspace on another interface.
|
|
335
338
|
|
|
336
339
|
When the workspace is supervised by launchd (~/Library/LaunchAgents/com.dave.workspace.plist)
|
|
337
340
|
or systemd (~/.config/systemd/user/dave-workspace.service), \`stop\` is refused and \`restart\`
|
|
@@ -790,6 +790,16 @@ export async function sopsDecrypt(paths, filePath) {
|
|
|
790
790
|
* scoped-vault fixtures use). Order is stable, duplicates deduplicated.
|
|
791
791
|
* Returns an empty array on any read failure. This helper only feeds a
|
|
792
792
|
* diagnostic message and must never throw over its own IO.
|
|
793
|
+
*
|
|
794
|
+
* NOTE: this reads the UNION of recipients across every creation_rule
|
|
795
|
+
* in the file. Scoped `.sops.yaml` configs typically declare one rule
|
|
796
|
+
* per encrypted file, and a caller who is a recipient in ONE scope but
|
|
797
|
+
* not another still matches this union. That is why the false-absence
|
|
798
|
+
* diagnostic surfaced on ops-01 (w-2026-09-03-dave-011): the caller's
|
|
799
|
+
* pubkey belonged to the seo-ops rule, not the hq-estate rule, but the
|
|
800
|
+
* union read showed it. Use `recipientsForTargetFile` to get the list
|
|
801
|
+
* for a specific file instead; the union stays here as a strict
|
|
802
|
+
* fallback for configs the narrow parser cannot recognise.
|
|
793
803
|
*/
|
|
794
804
|
export function readRecipientsFromSopsConfig(sopsConfigPath) {
|
|
795
805
|
let text;
|
|
@@ -812,31 +822,204 @@ export function readRecipientsFromSopsConfig(sopsConfigPath) {
|
|
|
812
822
|
return out;
|
|
813
823
|
}
|
|
814
824
|
|
|
825
|
+
/**
|
|
826
|
+
* Parse creation_rules from a sops config file and return the recipient
|
|
827
|
+
* age pubkeys for the first rule whose `path_regex` matches the given
|
|
828
|
+
* encrypted file's basename. Returns null when the parser cannot pick a
|
|
829
|
+
* matching rule (caller falls back to the union read). Returns an empty
|
|
830
|
+
* array for a matching rule whose `age:` field is empty.
|
|
831
|
+
*
|
|
832
|
+
* The parser is deliberately narrow: it splits into blocks starting at
|
|
833
|
+
* `- path_regex:` lines and reads two fields per block, `path_regex`
|
|
834
|
+
* and `age`. Both inline (`age: "age1a,age1b"`) and folded multi-line
|
|
835
|
+
* (`age: >-\n age1a\n age1b`) shapes are handled: the two forms the
|
|
836
|
+
* estate's provisioning tooling emits. YAML this parser cannot
|
|
837
|
+
* recognise falls back to the union at the caller, so a partial config
|
|
838
|
+
* still produces a truthful message rather than a silent absence claim.
|
|
839
|
+
*
|
|
840
|
+
* Root-cause cure for w-2026-09-03-dave-011: `readRecipientsFromSopsConfig`
|
|
841
|
+
* unioned pubkeys across every creation_rule, so on ops-01 the caller
|
|
842
|
+
* key (recipient of seo-ops, not hq-estate) appeared in the printed
|
|
843
|
+
* "recipients" list, contradicting the "not in the recipient list"
|
|
844
|
+
* assertion. Scoping the recipient list to the actual target file's
|
|
845
|
+
* matching rule keeps the wording honest.
|
|
846
|
+
*/
|
|
847
|
+
export function recipientsForTargetFile(sopsConfigPath, targetFile) {
|
|
848
|
+
let text;
|
|
849
|
+
try {
|
|
850
|
+
text = readFileSync(sopsConfigPath, 'utf-8');
|
|
851
|
+
} catch {
|
|
852
|
+
return null;
|
|
853
|
+
}
|
|
854
|
+
if (!targetFile) return null;
|
|
855
|
+
const targetName = basename(targetFile);
|
|
856
|
+
const lines = text.split('\n');
|
|
857
|
+
const rules = [];
|
|
858
|
+
let current = null;
|
|
859
|
+
let foldingAge = false;
|
|
860
|
+
const ruleStartRe = /^\s*-\s*path_regex:\s*(.+?)\s*$/;
|
|
861
|
+
const inlineAgeRe = /^\s*age:\s*"?([^"]*?)"?\s*$/;
|
|
862
|
+
const foldedAgeStartRe = /^\s*age:\s*>-?\s*$/;
|
|
863
|
+
const ageInlineExtract = /age1[0-9a-z]+/g;
|
|
864
|
+
|
|
865
|
+
for (const line of lines) {
|
|
866
|
+
const ruleStart = ruleStartRe.exec(line);
|
|
867
|
+
if (ruleStart) {
|
|
868
|
+
if (current) rules.push(current);
|
|
869
|
+
current = { pathRegex: ruleStart[1], age: [] };
|
|
870
|
+
foldingAge = false;
|
|
871
|
+
continue;
|
|
872
|
+
}
|
|
873
|
+
if (!current) continue;
|
|
874
|
+
if (foldingAge) {
|
|
875
|
+
// Folded block ends at a blank line, a new key at the block's
|
|
876
|
+
// indent, or a new list item. Anything else is a wrapped value.
|
|
877
|
+
const trimmed = line.trim();
|
|
878
|
+
if (trimmed === '' || /^[A-Za-z_-]+\s*:/.test(trimmed) || /^-\s/.test(trimmed)) {
|
|
879
|
+
foldingAge = false;
|
|
880
|
+
// fall through so this line can still start a new rule below
|
|
881
|
+
} else {
|
|
882
|
+
for (const match of trimmed.matchAll(ageInlineExtract)) current.age.push(match[0]);
|
|
883
|
+
continue;
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
if (foldedAgeStartRe.test(line)) {
|
|
887
|
+
foldingAge = true;
|
|
888
|
+
continue;
|
|
889
|
+
}
|
|
890
|
+
const inline = inlineAgeRe.exec(line);
|
|
891
|
+
if (inline) {
|
|
892
|
+
for (const match of inline[1].matchAll(ageInlineExtract)) current.age.push(match[0]);
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
if (current) rules.push(current);
|
|
896
|
+
|
|
897
|
+
for (const rule of rules) {
|
|
898
|
+
let re;
|
|
899
|
+
try { re = new RegExp(rule.pathRegex); } catch { continue; }
|
|
900
|
+
if (re.test(targetName)) {
|
|
901
|
+
const seen = new Set();
|
|
902
|
+
const out = [];
|
|
903
|
+
for (const k of rule.age) if (!seen.has(k)) { seen.add(k); out.push(k); }
|
|
904
|
+
return out;
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
return null;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
/**
|
|
911
|
+
* Read every `# public key: age1...` line from an age key file. A key
|
|
912
|
+
* file with a single identity yields one entry; a file with multiple
|
|
913
|
+
* identities yields one entry per identity, in file order (the order
|
|
914
|
+
* sops tries them when SOPS_AGE_KEY_FILE points at the file). Returns
|
|
915
|
+
* an empty array on any read failure or when no comment can be parsed.
|
|
916
|
+
* Only feeds diagnostic output: never throws over its own IO.
|
|
917
|
+
*/
|
|
918
|
+
export function publicKeysFromKeyFile(keyFile) {
|
|
919
|
+
let contents;
|
|
920
|
+
try {
|
|
921
|
+
contents = readFileSync(keyFile, 'utf-8');
|
|
922
|
+
} catch {
|
|
923
|
+
return [];
|
|
924
|
+
}
|
|
925
|
+
const out = [];
|
|
926
|
+
const seen = new Set();
|
|
927
|
+
const re = /public key:\s*(age1[0-9a-z]+)/gi;
|
|
928
|
+
let m;
|
|
929
|
+
while ((m = re.exec(contents)) !== null) {
|
|
930
|
+
if (!seen.has(m[1])) {
|
|
931
|
+
seen.add(m[1]);
|
|
932
|
+
out.push(m[1]);
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
return out;
|
|
936
|
+
}
|
|
937
|
+
|
|
815
938
|
/**
|
|
816
939
|
* Build the single stderr diagnostic line that names, without ever
|
|
817
940
|
* decrypting anything, WHY a decrypt failed: the scope (or vault path),
|
|
818
|
-
*
|
|
819
|
-
*
|
|
820
|
-
*
|
|
821
|
-
*
|
|
822
|
-
*
|
|
941
|
+
* every candidate age public key the caller's key file contains (the
|
|
942
|
+
* exact set sops tries when SOPS_AGE_KEY_FILE points at that file, in
|
|
943
|
+
* file order), the target file's recipient list (scoped to the rule
|
|
944
|
+
* whose path_regex matches, when the parser can pin one, otherwise the
|
|
945
|
+
* union of all rules), and a truthful "is / is not in the list" verdict.
|
|
946
|
+
*
|
|
947
|
+
* Cures two live defects (w-2026-09-03-dave-011):
|
|
948
|
+
* 1. A file with multiple identities used to report only the first
|
|
949
|
+
* `public key:` line. Now every identity in the key file is named.
|
|
950
|
+
* 2. The wording used to assert absence unconditionally, printing a
|
|
951
|
+
* recipient list drawn from every creation_rule in the sops config.
|
|
952
|
+
* When the caller's key was a recipient of ANOTHER rule the printed
|
|
953
|
+
* list contradicted the assertion. Now the recipients are scoped
|
|
954
|
+
* to the target's own rule, and the message states membership
|
|
955
|
+
* truthfully: never claims absence when the list contains the key.
|
|
823
956
|
*/
|
|
824
957
|
export function formatDecryptFailure(paths) {
|
|
825
958
|
const target = paths.scope
|
|
826
959
|
? `scope "${paths.scope}"`
|
|
827
960
|
: (paths.secretsFile ? `vault ${paths.secretsFile}` : 'vault');
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
961
|
+
|
|
962
|
+
const callerPubs = paths.keyFile ? publicKeysFromKeyFile(paths.keyFile) : [];
|
|
963
|
+
|
|
964
|
+
// Prefer the recipient list of the specific target file's rule. Fall
|
|
965
|
+
// back to the union of every rule when the narrow parser cannot pin
|
|
966
|
+
// one; the wording checks membership either way, so falling back does
|
|
967
|
+
// not reintroduce the false-absence class the union alone caused.
|
|
968
|
+
let scopedRecipients = null;
|
|
969
|
+
if (paths.sopsConfig && paths.secretsFile) {
|
|
970
|
+
scopedRecipients = recipientsForTargetFile(paths.sopsConfig, paths.secretsFile);
|
|
971
|
+
}
|
|
972
|
+
const unionRecipients = paths.sopsConfig ? readRecipientsFromSopsConfig(paths.sopsConfig) : [];
|
|
973
|
+
const recipients = scopedRecipients ?? unionRecipients;
|
|
974
|
+
const recipientSet = new Set(recipients);
|
|
975
|
+
const matched = callerPubs.filter((k) => recipientSet.has(k));
|
|
976
|
+
|
|
977
|
+
const keyFileDesc = paths.keyFile ? ` (from ${paths.keyFile})` : '';
|
|
978
|
+
let callerDesc;
|
|
979
|
+
if (callerPubs.length === 0) {
|
|
980
|
+
callerDesc = paths.keyFile
|
|
981
|
+
? `no age public key could be parsed from ${paths.keyFile}`
|
|
982
|
+
: 'no age key file path known';
|
|
983
|
+
} else if (callerPubs.length === 1) {
|
|
984
|
+
callerDesc = `age key ${callerPubs[0]}${keyFileDesc}`;
|
|
985
|
+
} else {
|
|
986
|
+
callerDesc = `age keys ${callerPubs.join(', ')}${keyFileDesc}`;
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
const recipientsLabel = scopedRecipients && paths.secretsFile
|
|
990
|
+
? `recipients for ${basename(paths.secretsFile)} (from ${paths.sopsConfig})`
|
|
991
|
+
: `recipients (from ${paths.sopsConfig ?? '<no sops config>'})`;
|
|
836
992
|
const recipientsDesc = recipients.length > 0
|
|
837
|
-
?
|
|
993
|
+
? `${recipientsLabel}: ${recipients.join(', ')}`
|
|
838
994
|
: `recipients could not be read from ${paths.sopsConfig ?? '<no sops config>'}`;
|
|
839
|
-
|
|
995
|
+
|
|
996
|
+
if (matched.length > 0) {
|
|
997
|
+
// Truthful branch: sops still refused even though the caller keys
|
|
998
|
+
// ARE listed as intended recipients. The two live causes are stale
|
|
999
|
+
// recipient stanzas in the encrypted store (needs `sops updatekeys`)
|
|
1000
|
+
// and unreadable key file permissions.
|
|
1001
|
+
const matchDesc = matched.length === 1
|
|
1002
|
+
? `${matched[0]} IS listed as a recipient`
|
|
1003
|
+
: `${matched.join(', ')} ARE listed as recipients`;
|
|
1004
|
+
return (
|
|
1005
|
+
`Error: cannot decrypt ${target}: this host's ${matchDesc}, but sops still refused. ` +
|
|
1006
|
+
'Common causes: the encrypted store predates this recipient stanza ' +
|
|
1007
|
+
'(someone with decrypt access must run `sops updatekeys` on it), ' +
|
|
1008
|
+
'or the key file permissions block sops from reading it. ' +
|
|
1009
|
+
`${recipientsDesc}. Caller key file: ${callerDesc}.`
|
|
1010
|
+
);
|
|
1011
|
+
}
|
|
1012
|
+
if (callerPubs.length === 0) {
|
|
1013
|
+
return (
|
|
1014
|
+
`Error: cannot decrypt ${target}: ${callerDesc}. ${recipientsDesc}. ` +
|
|
1015
|
+
"Fix: restore your age key from backup, or run 'secrets init' to start a fresh vault."
|
|
1016
|
+
);
|
|
1017
|
+
}
|
|
1018
|
+
return (
|
|
1019
|
+
`Error: cannot decrypt ${target}: this host's ${callerDesc} is not in the recipient list; ` +
|
|
1020
|
+
`${recipientsDesc}. ` +
|
|
1021
|
+
"Fix: add this host's public key to the scope's recipients, or run this verb from a host that already has decrypt access."
|
|
1022
|
+
);
|
|
840
1023
|
}
|
|
841
1024
|
|
|
842
1025
|
/**
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// tier-map.mjs: default `library put` tier from the source path.
|
|
2
|
+
//
|
|
3
|
+
// The `library put` CLI used to default `--tier` to the `auto` sentinel
|
|
4
|
+
// unconditionally. For content-bearing kinds like `profile.slice.upsert`
|
|
5
|
+
// that meant the ingest engine's routing rules matched by CONTENT only,
|
|
6
|
+
// and a slice from operator/profile/slices/ landed on the hq tier during
|
|
7
|
+
// the 2026-09-03 self-push proof (w-2026-09-03-dave-017; hq commit
|
|
8
|
+
// 15aacb2 carried a stray operator/profile/slices/dave-boot.md). The
|
|
9
|
+
// CLI already knows the source path. The path already encodes the tier
|
|
10
|
+
// for every file under operator/profile/ by estate convention, so we
|
|
11
|
+
// pick the routing default from the path before the envelope is built.
|
|
12
|
+
//
|
|
13
|
+
// Rules (2026-09-04, w-2026-09-03-dave-017):
|
|
14
|
+
// operator/profile/career-context.md -> stravica-operator-barry-private
|
|
15
|
+
// operator/profile/private-context.md -> stravica-operator-barry-private
|
|
16
|
+
// operator/profile/health-context.md -> stravica-operator-barry-private
|
|
17
|
+
// operator/profile/** (incl. slices/) -> stravica-operator-barry
|
|
18
|
+
// (everything else) -> null (caller keeps `auto`)
|
|
19
|
+
//
|
|
20
|
+
// Every path outside the map keeps the pre-cure `auto` default, so
|
|
21
|
+
// unrelated payload kinds still content-route the way they always did.
|
|
22
|
+
// This is the CLI half of the fix. The server side (a `sourcePath`
|
|
23
|
+
// prefix match in the routing engine) is a companion change in the
|
|
24
|
+
// remote-library repo and is not touched here.
|
|
25
|
+
|
|
26
|
+
import { isAbsolute, relative } from 'node:path';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* First-match ordered list of path prefix rules. Callers use
|
|
30
|
+
* `deriveTierFromPath` rather than reading this directly.
|
|
31
|
+
*/
|
|
32
|
+
export const PATH_TIER_RULES = [
|
|
33
|
+
{ match: (p) => p === 'operator/profile/career-context.md', tier: 'stravica-operator-barry-private' },
|
|
34
|
+
{ match: (p) => p === 'operator/profile/private-context.md', tier: 'stravica-operator-barry-private' },
|
|
35
|
+
{ match: (p) => p === 'operator/profile/health-context.md', tier: 'stravica-operator-barry-private' },
|
|
36
|
+
{ match: (p) => p.startsWith('operator/profile/'), tier: 'stravica-operator-barry' },
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Normalise a caller-supplied path to a repo-relative POSIX form.
|
|
41
|
+
* Returns null when the path resolves outside the repo root: routing
|
|
42
|
+
* defaults do not apply outside the workspace, and the envelope encoder
|
|
43
|
+
* refuses that shape anyway (assertRepoRelativeSafe in envelope.mjs).
|
|
44
|
+
*
|
|
45
|
+
* @param {string} sourcePath absolute or repo-relative
|
|
46
|
+
* @param {string} repoRoot resolved repo root
|
|
47
|
+
* @returns {string|null} repo-relative POSIX path, or null
|
|
48
|
+
*/
|
|
49
|
+
export function toRepoRelative(sourcePath, repoRoot) {
|
|
50
|
+
if (!sourcePath || !repoRoot) return null;
|
|
51
|
+
const rel = isAbsolute(sourcePath) ? relative(repoRoot, sourcePath) : sourcePath;
|
|
52
|
+
const norm = rel.replaceAll('\\', '/');
|
|
53
|
+
if (norm.startsWith('../') || norm === '..' || norm === '') return null;
|
|
54
|
+
return norm;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Match a source path against the tier map and return the derived tier,
|
|
59
|
+
* or null when no rule matches.
|
|
60
|
+
*
|
|
61
|
+
* @param {string} sourcePath
|
|
62
|
+
* @param {string} repoRoot
|
|
63
|
+
* @returns {string|null}
|
|
64
|
+
*/
|
|
65
|
+
export function deriveTierFromPath(sourcePath, repoRoot) {
|
|
66
|
+
const rel = toRepoRelative(sourcePath, repoRoot);
|
|
67
|
+
if (!rel) return null;
|
|
68
|
+
for (const rule of PATH_TIER_RULES) {
|
|
69
|
+
if (rule.match(rel)) return rule.tier;
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Refuse a caller-asserted tier that disagrees with the path-derived
|
|
76
|
+
* default. Returns null when the assertion is compatible (including the
|
|
77
|
+
* `auto` sentinel and paths with no mapped default). Returns a
|
|
78
|
+
* single-line reason string otherwise, ready for the CLI to print
|
|
79
|
+
* before exiting 2.
|
|
80
|
+
*
|
|
81
|
+
* The refusal exists so a `--tier hq` on an operator/profile/ path
|
|
82
|
+
* cannot silently override the operator-tier default. `auto` means
|
|
83
|
+
* "let derivation decide" and is always accepted.
|
|
84
|
+
*
|
|
85
|
+
* @param {string} sourcePath
|
|
86
|
+
* @param {string} repoRoot
|
|
87
|
+
* @param {string} assertedTier
|
|
88
|
+
* @returns {string|null}
|
|
89
|
+
*/
|
|
90
|
+
export function assertTierMatchesPath(sourcePath, repoRoot, assertedTier) {
|
|
91
|
+
if (!assertedTier || assertedTier === 'auto') return null;
|
|
92
|
+
const derived = deriveTierFromPath(sourcePath, repoRoot);
|
|
93
|
+
if (derived === null) return null;
|
|
94
|
+
if (derived === assertedTier) return null;
|
|
95
|
+
const rel = toRepoRelative(sourcePath, repoRoot) ?? sourcePath;
|
|
96
|
+
return (
|
|
97
|
+
`refusing to route ${rel} to tier "${assertedTier}": ` +
|
|
98
|
+
`path maps to tier "${derived}" by the profile-path default. ` +
|
|
99
|
+
'Drop --tier to accept the default, or move the file before overriding.'
|
|
100
|
+
);
|
|
101
|
+
}
|
|
@@ -11,6 +11,7 @@ import { basename } from 'node:path';
|
|
|
11
11
|
import { buildEnvelope, subkeyIdFromRemoteKey } from '../envelope.mjs';
|
|
12
12
|
import { postEvents } from '../http.mjs';
|
|
13
13
|
import { parseIngestAck } from '../ack.mjs';
|
|
14
|
+
import { assertTierMatchesPath, deriveTierFromPath } from '../tier-map.mjs';
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
17
|
* @param {object} ctx
|
|
@@ -26,6 +27,26 @@ export async function runPut({ config, args, fetchImpl, log = defaultLog }) {
|
|
|
26
27
|
log('usage: library put <path> [--kind <kind>] [--tier auto|<tier>]');
|
|
27
28
|
return 1;
|
|
28
29
|
}
|
|
30
|
+
|
|
31
|
+
// Refuse a caller-forced tier that disagrees with the path-derived
|
|
32
|
+
// default (w-2026-09-03-dave-017). `auto` never triggers the refusal
|
|
33
|
+
// (auto means "let derivation choose"); an explicit tier does. The
|
|
34
|
+
// refusal is a clean exit 2 with a single-line reason so an operator
|
|
35
|
+
// sees why the CLI declined to encode the event rather than silently
|
|
36
|
+
// stamping the wrong tier.
|
|
37
|
+
const tierRefusal = assertTierMatchesPath(path, config.repoRoot, tier);
|
|
38
|
+
if (tierRefusal) {
|
|
39
|
+
log(`[library put] refused: ${tierRefusal}`);
|
|
40
|
+
return 2;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// When `--tier` is absent the CLI derives it from the path map. Paths
|
|
44
|
+
// outside the map keep today's `auto` sentinel so unrelated payload
|
|
45
|
+
// kinds still content-route the way they always did.
|
|
46
|
+
const derivedTier = tier === 'auto'
|
|
47
|
+
? (deriveTierFromPath(path, config.repoRoot) ?? 'auto')
|
|
48
|
+
: tier;
|
|
49
|
+
|
|
29
50
|
// Delete events carry no content by spec (§3.1 payload schema); the
|
|
30
51
|
// operator can and typically will have removed the file locally before
|
|
31
52
|
// asking the tier remote to catch up. Reading a file that does not
|
|
@@ -42,7 +63,7 @@ export async function runPut({ config, args, fetchImpl, log = defaultLog }) {
|
|
|
42
63
|
content,
|
|
43
64
|
producer,
|
|
44
65
|
kind,
|
|
45
|
-
tier,
|
|
66
|
+
tier: derivedTier,
|
|
46
67
|
sourceKind: 'manual',
|
|
47
68
|
});
|
|
48
69
|
|
|
@@ -56,7 +77,7 @@ export async function runPut({ config, args, fetchImpl, log = defaultLog }) {
|
|
|
56
77
|
// RB-5 fix (2026-08-20 rehearsal). See ack.mjs for the wire contract.
|
|
57
78
|
const ack = parseIngestAck(res);
|
|
58
79
|
if (ack.kind === 'accepted' || ack.kind === 'deduped') {
|
|
59
|
-
log(`[library put] ${ack.kind} ${basename(path)} -> ${ack.eventId}`);
|
|
80
|
+
log(`[library put] ${ack.kind} ${basename(path)} -> ${ack.eventId} (tier: ${derivedTier})`);
|
|
60
81
|
return 0;
|
|
61
82
|
}
|
|
62
83
|
log(`[library put] rejected ${basename(path)}: ${ack.reason}`);
|
|
@@ -686,38 +686,49 @@ function processOnboarding(body, repoRoot) {
|
|
|
686
686
|
// Non-fatal: git may not be available or commit may fail
|
|
687
687
|
}
|
|
688
688
|
|
|
689
|
-
// 7. Vault
|
|
689
|
+
// 7. Vault backup instruction (no key material over HTTP).
|
|
690
690
|
//
|
|
691
|
-
//
|
|
692
|
-
//
|
|
693
|
-
//
|
|
694
|
-
//
|
|
695
|
-
//
|
|
696
|
-
//
|
|
697
|
-
//
|
|
698
|
-
//
|
|
699
|
-
|
|
691
|
+
// Historical note. Earlier revisions of this handler spawned a stale
|
|
692
|
+
// v1 vault binary and, on success, read `~/.config/dave/age-key.txt` off
|
|
693
|
+
// disk so the browser could render the secret key as a QR code for
|
|
694
|
+
// one-time backup. That is a private key over an unauthenticated local
|
|
695
|
+
// HTTP endpoint on a `*:4200` bind — see the P0 audit under
|
|
696
|
+
// w-2026-09-13-dave-024. No response from this endpoint ever carries
|
|
697
|
+
// private key material. The UI shows the operator a one-line instruction
|
|
698
|
+
// to run the CLI backup command instead.
|
|
699
|
+
const vault = {
|
|
700
|
+
initialised: false,
|
|
701
|
+
publicKey: null,
|
|
702
|
+
error: null,
|
|
703
|
+
backupInstruction: 'Run `pnpm exec secrets backup` from a terminal to view and store your recovery key.',
|
|
704
|
+
};
|
|
700
705
|
try {
|
|
701
|
-
const
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
const
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
706
|
+
const recipientsFile = join(repoRoot, '.vault', 'recipients.txt');
|
|
707
|
+
if (existsSync(recipientsFile)) {
|
|
708
|
+
// recipients.txt is a plain-text list of AGE public keys, one per
|
|
709
|
+
// line. Return the first strictly-validated `age1...` recipient
|
|
710
|
+
// rather than trusting the file contents verbatim: a bug elsewhere,
|
|
711
|
+
// a misplaced key file, or an operator paste can put arbitrary
|
|
712
|
+
// content in this path, and this endpoint is the last mile before
|
|
713
|
+
// it lands in an HTTP response body. AGE public keys are
|
|
714
|
+
// Bech32-encoded (58 characters after the `age1` prefix, all
|
|
715
|
+
// lowercase alphanumerics from Bech32's charset). AGE secret keys
|
|
716
|
+
// are prefixed `AGE-SECRET-KEY-` and are rejected by this regex.
|
|
717
|
+
const recipientsRaw = readFileSync(recipientsFile, 'utf-8');
|
|
718
|
+
const validRecipients = recipientsRaw
|
|
719
|
+
.split(/\r?\n/)
|
|
720
|
+
.map(function(line) { return line.trim(); })
|
|
721
|
+
// Public key shape: starts with `age1`, lowercase Bech32 body.
|
|
722
|
+
// The negative match on `AGE-SECRET-KEY-` is redundant given the
|
|
723
|
+
// `age1` prefix rule but stays as an explicit belt-and-braces.
|
|
724
|
+
.filter(function(line) {
|
|
725
|
+
if (!/^age1[a-z0-9]{58,}$/.test(line)) return false;
|
|
726
|
+
if (line.includes('AGE-SECRET-KEY-')) return false;
|
|
727
|
+
return true;
|
|
728
|
+
});
|
|
729
|
+
if (validRecipients.length > 0) {
|
|
730
|
+
vault.initialised = true;
|
|
731
|
+
vault.publicKey = validRecipients[0];
|
|
721
732
|
}
|
|
722
733
|
}
|
|
723
734
|
} catch (e) {
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* guards.mjs — request guards for the workspace HTTP server.
|
|
3
|
+
*
|
|
4
|
+
* Three concerns:
|
|
5
|
+
* 1. Host header allowlist. Even bound to 127.0.0.1, a hostile domain that
|
|
6
|
+
* resolves to 127.0.0.1 (DNS rebinding) can drive a browser to send a
|
|
7
|
+
* request whose Host is the hostile domain. Requests whose Host is
|
|
8
|
+
* not on the allowlist get 403. Enforced at the top of the outer
|
|
9
|
+
* request handler so every route — static or API — is covered.
|
|
10
|
+
* 2. Origin allowlist on state-changing methods. Any web page open in the
|
|
11
|
+
* operator's browser can POST to http://localhost:4200 with `mode:
|
|
12
|
+
* no-cors`. When an Origin header is present it must match the
|
|
13
|
+
* workspace's own origin; anything else gets 403.
|
|
14
|
+
* 3. Sec-Fetch-Site guard. Modern browsers stamp cross-site requests with
|
|
15
|
+
* Sec-Fetch-Site: cross-site (or same-site). When the header is
|
|
16
|
+
* present it must be `same-origin` or `none`.
|
|
17
|
+
*
|
|
18
|
+
* The websocket upgrade path shares the same allowlists — see server.mjs'
|
|
19
|
+
* server.on('upgrade', ...) hook. No WebSocket endpoint exists in the
|
|
20
|
+
* workspace today; the upgrade handler rejects all upgrades defensively so
|
|
21
|
+
* a future endpoint has to opt in explicitly. Upgrades run with
|
|
22
|
+
* `alwaysCheckOrigin: true` because RFC 6455 pins the upgrade verb to GET,
|
|
23
|
+
* which would otherwise skip the state-changing-method branch.
|
|
24
|
+
*
|
|
25
|
+
* All exports are pure functions; the HTTP wiring lives in server.mjs.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Loopback and unspecified hosts that must not add themselves to the Host
|
|
30
|
+
* allowlist as an extra entry (they're already covered by the loopback
|
|
31
|
+
* triple, or they're a wildcard that would defeat the check).
|
|
32
|
+
*/
|
|
33
|
+
const NON_HOST_BINDS = new Set(['127.0.0.1', 'localhost', '::1', '::', '0.0.0.0']);
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Methods that never mutate server state. Anything outside this set is
|
|
37
|
+
* treated as state-changing for the Origin / Sec-Fetch-Site sweep. This is
|
|
38
|
+
* the CONNECT-safe form of the check; a future custom extension verb (or
|
|
39
|
+
* a HEAD that gets abused as a channel for side effects — belt and braces)
|
|
40
|
+
* still gets the CSRF gate.
|
|
41
|
+
*/
|
|
42
|
+
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Build the Host header allowlist. Always includes localhost, 127.0.0.1
|
|
46
|
+
* and [::1] on the workspace port. When the operator has opted into a
|
|
47
|
+
* non-loopback bind (WORKSPACE_HOST env var or --host flag), that host is
|
|
48
|
+
* added too.
|
|
49
|
+
*/
|
|
50
|
+
export function buildAllowedHosts(port, extraHost) {
|
|
51
|
+
const hosts = new Set([
|
|
52
|
+
`localhost:${port}`,
|
|
53
|
+
`127.0.0.1:${port}`,
|
|
54
|
+
`[::1]:${port}`,
|
|
55
|
+
]);
|
|
56
|
+
if (extraHost && !NON_HOST_BINDS.has(extraHost)) {
|
|
57
|
+
hosts.add(`${extraHost}:${port}`.toLowerCase());
|
|
58
|
+
}
|
|
59
|
+
return hosts;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Build the Origin allowlist. Same shape as the Host allowlist, but with an
|
|
64
|
+
* `http://` scheme prefix. HTTPS termination for a remote bind is out of
|
|
65
|
+
* scope for this fix.
|
|
66
|
+
*/
|
|
67
|
+
export function buildAllowedOrigins(port, extraHost) {
|
|
68
|
+
const origins = new Set([
|
|
69
|
+
`http://localhost:${port}`,
|
|
70
|
+
`http://127.0.0.1:${port}`,
|
|
71
|
+
`http://[::1]:${port}`,
|
|
72
|
+
]);
|
|
73
|
+
if (extraHost && !NON_HOST_BINDS.has(extraHost)) {
|
|
74
|
+
origins.add(`http://${extraHost}:${port}`.toLowerCase());
|
|
75
|
+
}
|
|
76
|
+
return origins;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Count occurrences of a header name in `req.rawHeaders`. Node's
|
|
81
|
+
* `req.headers` normalises duplicates by joining them with a comma
|
|
82
|
+
* (silently for most headers) or picking the first (for a handful of
|
|
83
|
+
* single-value headers), so a hostile request that sends two Host headers
|
|
84
|
+
* — one aliased to the loopback, one not — could pass the normal check
|
|
85
|
+
* even though a downstream reader might see the other one. We refuse the
|
|
86
|
+
* request when any of Host / Origin / Sec-Fetch-Site occurs more than
|
|
87
|
+
* once, so the ambiguity never reaches the handler.
|
|
88
|
+
*/
|
|
89
|
+
function countRawHeader(rawHeaders, name) {
|
|
90
|
+
if (!Array.isArray(rawHeaders)) return 0;
|
|
91
|
+
const target = name.toLowerCase();
|
|
92
|
+
let count = 0;
|
|
93
|
+
for (let i = 0; i < rawHeaders.length; i += 2) {
|
|
94
|
+
const key = rawHeaders[i];
|
|
95
|
+
if (typeof key === 'string' && key.toLowerCase() === target) count++;
|
|
96
|
+
}
|
|
97
|
+
return count;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Check the request headers against the Host/Origin/Sec-Fetch-Site rules.
|
|
102
|
+
*
|
|
103
|
+
* Options:
|
|
104
|
+
* - onlyCheckHost: skip the Origin / Sec-Fetch-Site branch (used by the
|
|
105
|
+
* outer per-request pass; the inner API-branch call runs the full
|
|
106
|
+
* check).
|
|
107
|
+
* - alwaysCheckOrigin: always run Origin / Sec-Fetch-Site, even for
|
|
108
|
+
* GET/HEAD/OPTIONS. Used by the upgrade handler because RFC 6455 pins
|
|
109
|
+
* the upgrade verb to GET.
|
|
110
|
+
*
|
|
111
|
+
* Returns { ok: true } when the request passes, otherwise
|
|
112
|
+
* { ok: false, status, reason } with a 403 status and a short reason
|
|
113
|
+
* string (host | origin | sec-fetch-site | duplicate-host |
|
|
114
|
+
* duplicate-origin | duplicate-sec-fetch-site) for logging.
|
|
115
|
+
*/
|
|
116
|
+
export function checkRequestGuards(req, allowedHosts, allowedOrigins, options) {
|
|
117
|
+
const opts = options || {};
|
|
118
|
+
const headers = req.headers || {};
|
|
119
|
+
const raw = req.rawHeaders;
|
|
120
|
+
|
|
121
|
+
if (countRawHeader(raw, 'host') > 1) {
|
|
122
|
+
return { ok: false, status: 403, reason: 'duplicate-host' };
|
|
123
|
+
}
|
|
124
|
+
const host = typeof headers.host === 'string' ? headers.host.toLowerCase() : '';
|
|
125
|
+
if (!host || !allowedHosts.has(host)) {
|
|
126
|
+
return { ok: false, status: 403, reason: 'host' };
|
|
127
|
+
}
|
|
128
|
+
if (opts.onlyCheckHost) return { ok: true };
|
|
129
|
+
|
|
130
|
+
const method = (req.method || 'GET').toUpperCase();
|
|
131
|
+
const runOriginBranch = opts.alwaysCheckOrigin === true || !SAFE_METHODS.has(method);
|
|
132
|
+
if (runOriginBranch) {
|
|
133
|
+
if (countRawHeader(raw, 'origin') > 1) {
|
|
134
|
+
return { ok: false, status: 403, reason: 'duplicate-origin' };
|
|
135
|
+
}
|
|
136
|
+
if (countRawHeader(raw, 'sec-fetch-site') > 1) {
|
|
137
|
+
return { ok: false, status: 403, reason: 'duplicate-sec-fetch-site' };
|
|
138
|
+
}
|
|
139
|
+
const origin = typeof headers.origin === 'string' ? headers.origin.toLowerCase() : '';
|
|
140
|
+
if (origin && !allowedOrigins.has(origin)) {
|
|
141
|
+
return { ok: false, status: 403, reason: 'origin' };
|
|
142
|
+
}
|
|
143
|
+
const sfs = typeof headers['sec-fetch-site'] === 'string' ? headers['sec-fetch-site'].toLowerCase() : '';
|
|
144
|
+
if (sfs && sfs !== 'same-origin' && sfs !== 'none') {
|
|
145
|
+
return { ok: false, status: 403, reason: 'sec-fetch-site' };
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return { ok: true };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Convenience: does the pathname trigger the API guard? The guard covers
|
|
153
|
+
* both the newer `/__api/*` surface and the legacy `/api/*` surface (used by
|
|
154
|
+
* the onboarding POST). Static pages under `/` still run the outer Host
|
|
155
|
+
* check but do not need the full API guard sweep — the outer pass covers
|
|
156
|
+
* them.
|
|
157
|
+
*/
|
|
158
|
+
export function isGuardedPath(pathname) {
|
|
159
|
+
if (!pathname) return false;
|
|
160
|
+
return pathname.startsWith('/__api/') || pathname.startsWith('/api/');
|
|
161
|
+
}
|
|
@@ -16,6 +16,9 @@
|
|
|
16
16
|
* node server.mjs --no-open Don't auto-open browser
|
|
17
17
|
* node server.mjs --timeout 60 Idle timeout in minutes (default: 120)
|
|
18
18
|
* node server.mjs --no-timeout Disable idle timeout
|
|
19
|
+
* node server.mjs --host 0.0.0.0 Bind host (default 127.0.0.1). Also
|
|
20
|
+
* WORKSPACE_HOST env var. A non-loopback
|
|
21
|
+
* bind prints a warning at startup.
|
|
19
22
|
*/
|
|
20
23
|
|
|
21
24
|
import { createServer } from 'node:http';
|
|
@@ -26,6 +29,7 @@ import { resolve, join, extname } from 'node:path';
|
|
|
26
29
|
import { fileURLToPath } from 'node:url';
|
|
27
30
|
import { handleApi } from './api.mjs';
|
|
28
31
|
import { loadNav } from './nav.mjs';
|
|
32
|
+
import { buildAllowedHosts, buildAllowedOrigins, checkRequestGuards, isGuardedPath } from './guards.mjs';
|
|
29
33
|
import { readTeamScopeConfigSync } from '../../engineering-pa-core/src/desk/team-scope.mjs';
|
|
30
34
|
import { readPreferencesSync } from '../../engineering-pa-core/src/preferences.mjs';
|
|
31
35
|
import { startPoller as startStatusMonPoller } from './status-mon/poller.mjs';
|
|
@@ -90,6 +94,13 @@ let autoOpen = true;
|
|
|
90
94
|
let timeoutMinutes = 120;
|
|
91
95
|
let noTimeout = false;
|
|
92
96
|
|
|
97
|
+
// Bind host. Defaults to loopback (127.0.0.1) — a request from anywhere
|
|
98
|
+
// off-box gets a TCP refuse, not a 403. The operator opts into a wider bind
|
|
99
|
+
// with --host <addr> or the WORKSPACE_HOST env var. When the resolved host
|
|
100
|
+
// is non-loopback the startup banner names the exposure so a mistake is
|
|
101
|
+
// visible in the log.
|
|
102
|
+
let host = process.env.WORKSPACE_HOST || '127.0.0.1';
|
|
103
|
+
|
|
93
104
|
// Read configured port from preferences if available (device-local overlay
|
|
94
105
|
// wins over the shared base — see @stravica-ai/engineering-pa-core/src/preferences.mjs).
|
|
95
106
|
try {
|
|
@@ -97,18 +108,41 @@ try {
|
|
|
97
108
|
if (prefs && prefs.workspacePort) port = prefs.workspacePort;
|
|
98
109
|
} catch { /* no prefs yet — use default */ }
|
|
99
110
|
|
|
100
|
-
// CLI flags override preferences
|
|
111
|
+
// CLI flags override preferences.
|
|
112
|
+
//
|
|
113
|
+
// --host <addr> is validated at the CLI edge. A missing value, an empty
|
|
114
|
+
// string, or a value that looks like the next flag would otherwise fall
|
|
115
|
+
// through as `undefined` and land on `server.listen(port, undefined, ...)`,
|
|
116
|
+
// which Node treats as a wildcard bind — the exact exposure this fix
|
|
117
|
+
// exists to close. Refuse loudly instead.
|
|
101
118
|
for (let i = 0; i < args.length; i++) {
|
|
102
119
|
switch (args[i]) {
|
|
103
120
|
case '--port': port = parseInt(args[++i], 10); break;
|
|
104
121
|
case '--no-open': autoOpen = false; break;
|
|
105
122
|
case '--timeout': timeoutMinutes = parseInt(args[++i], 10); break;
|
|
106
123
|
case '--no-timeout': noTimeout = true; break;
|
|
124
|
+
case '--host': {
|
|
125
|
+
const raw = args[++i];
|
|
126
|
+
if (typeof raw !== 'string' || raw.length === 0 || raw.startsWith('-')) {
|
|
127
|
+
console.error('Error: --host requires a non-empty address (received: ' + JSON.stringify(raw) + ')');
|
|
128
|
+
process.exit(1);
|
|
129
|
+
}
|
|
130
|
+
host = raw;
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
107
133
|
}
|
|
108
134
|
}
|
|
109
135
|
|
|
110
136
|
const IDLE_TIMEOUT_MS = timeoutMinutes * 60 * 1000;
|
|
111
137
|
|
|
138
|
+
// Host and Origin allowlists. Built once at startup — the port and host
|
|
139
|
+
// are pinned for the life of the process. `extraHost` is only added when
|
|
140
|
+
// the operator has opted into a non-loopback bind; loopback binds keep the
|
|
141
|
+
// allowlist to the three loopback aliases.
|
|
142
|
+
const ALLOWED_HOSTS = buildAllowedHosts(port, host);
|
|
143
|
+
const ALLOWED_ORIGINS = buildAllowedOrigins(port, host);
|
|
144
|
+
const IS_LOOPBACK_BIND = host === '127.0.0.1' || host === 'localhost' || host === '::1';
|
|
145
|
+
|
|
112
146
|
// Side-effect gate. This file is imported by tests to assert exports (Option
|
|
113
147
|
// D). When it is imported (not run), we must NOT attach status-mon or bind a
|
|
114
148
|
// port. Everything side-effectful below is guarded on this flag.
|
|
@@ -317,11 +351,55 @@ export function markdownPage(rawContent, filePath) {
|
|
|
317
351
|
|
|
318
352
|
// --- HTTP server ---
|
|
319
353
|
const server = createServer(async (req, res) => {
|
|
354
|
+
// Host allowlist runs on EVERY request, not only /__api/*. A DNS-rebinding
|
|
355
|
+
// attack can point a hostile domain at 127.0.0.1 and drive the browser to
|
|
356
|
+
// read repo-root files served under the static path — the API guard
|
|
357
|
+
// alone leaves that door open. Static file serving stays functional for
|
|
358
|
+
// any request whose Host is one of our own aliases; everything else is
|
|
359
|
+
// 403 before any URL parsing decides which handler runs.
|
|
360
|
+
//
|
|
361
|
+
// Absolute-form request targets (`GET http://foo/bar HTTP/1.1`) are
|
|
362
|
+
// rejected outright: Node still parses `req.url` from the request line,
|
|
363
|
+
// but the request-target authority and the Host header can then
|
|
364
|
+
// disagree, which the allowlist cannot see. Same-origin browser
|
|
365
|
+
// traffic never sends absolute-form.
|
|
366
|
+
if (typeof req.url === 'string' && /^https?:\/\//i.test(req.url)) {
|
|
367
|
+
warn('[workspace] absolute-form request target refused: ' + req.url);
|
|
368
|
+
res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
369
|
+
res.end('Bad Request');
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
const globalGuard = checkRequestGuards(req, ALLOWED_HOSTS, ALLOWED_ORIGINS, {
|
|
373
|
+
// At the outer layer we only need the Host check to cover DNS
|
|
374
|
+
// rebinding of static reads. The state-changing method guard fires
|
|
375
|
+
// again inside the API branch below, where every mutating route
|
|
376
|
+
// lives, so Origin / Sec-Fetch-Site checks are not weakened by the
|
|
377
|
+
// narrower outer pass.
|
|
378
|
+
onlyCheckHost: true,
|
|
379
|
+
});
|
|
380
|
+
if (!globalGuard.ok) {
|
|
381
|
+
warn('[workspace] global guard refused ' + req.method + ' ' + (req.url || '') + ' (' + globalGuard.reason + ')');
|
|
382
|
+
res.writeHead(globalGuard.status, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
383
|
+
res.end('Forbidden');
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
|
|
320
387
|
const url = new URL(req.url, 'http://localhost:' + port);
|
|
321
388
|
const pathname = decodeURIComponent(url.pathname);
|
|
322
389
|
|
|
323
|
-
// API routes -- extension routes shadow core; handleApi handles the rest
|
|
390
|
+
// API routes -- extension routes shadow core; handleApi handles the rest.
|
|
391
|
+
// The full Host / Origin / Sec-Fetch-Site guard fires here so an
|
|
392
|
+
// extension route can never accept a cross-site request either. The
|
|
393
|
+
// outer pass has already validated the Host header, so this call is
|
|
394
|
+
// effectively the state-changing-method sweep.
|
|
324
395
|
if (pathname.startsWith('/__api/') || pathname.startsWith('/api/')) {
|
|
396
|
+
const guard = checkRequestGuards(req, ALLOWED_HOSTS, ALLOWED_ORIGINS);
|
|
397
|
+
if (!guard.ok) {
|
|
398
|
+
warn('[workspace] guard refused ' + req.method + ' ' + pathname + ' (' + guard.reason + ')');
|
|
399
|
+
res.writeHead(guard.status, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
400
|
+
res.end('Forbidden');
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
325
403
|
const extRoute = extRoutes.find(r => r.method === req.method && r.pathname === pathname);
|
|
326
404
|
if (extRoute) { extRoute.handler(req, res, url, REPO_ROOT); return; }
|
|
327
405
|
|
|
@@ -464,6 +542,30 @@ const server = createServer(async (req, res) => {
|
|
|
464
542
|
}
|
|
465
543
|
});
|
|
466
544
|
|
|
545
|
+
// --- Upgrade handler ---
|
|
546
|
+
// The workspace does not expose a WebSocket endpoint today. Any incoming
|
|
547
|
+
// upgrade is either an accident or an attempt to smuggle a state-changing
|
|
548
|
+
// request past the HTTP guard, so we run the Host/Origin/Sec-Fetch-Site
|
|
549
|
+
// guard first (403 to keep symmetry with the HTTP surface) and destroy the
|
|
550
|
+
// socket otherwise. A future upgrade endpoint has to attach its own
|
|
551
|
+
// listener and must respect the same allowlists.
|
|
552
|
+
server.on('upgrade', (req, socket) => {
|
|
553
|
+
// Upgrade requests are pinned to GET by RFC 6455, so the standard
|
|
554
|
+
// state-changing-method branch would skip Origin / Sec-Fetch-Site.
|
|
555
|
+
// `alwaysCheckOrigin` forces the full sweep so a future upgrade
|
|
556
|
+
// endpoint has to opt out consciously and can never inherit a weaker
|
|
557
|
+
// check by accident.
|
|
558
|
+
const guard = checkRequestGuards(req, ALLOWED_HOSTS, ALLOWED_ORIGINS, { alwaysCheckOrigin: true });
|
|
559
|
+
if (!guard.ok) {
|
|
560
|
+
warn('[workspace] upgrade refused (guard: ' + guard.reason + ')');
|
|
561
|
+
socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');
|
|
562
|
+
} else {
|
|
563
|
+
warn('[workspace] upgrade refused (no upgrade handler installed)');
|
|
564
|
+
socket.write('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n');
|
|
565
|
+
}
|
|
566
|
+
socket.destroy();
|
|
567
|
+
});
|
|
568
|
+
|
|
467
569
|
// --- Extension loader ---
|
|
468
570
|
// Imports operator/extensions/workspace/api.mjs (if present) and calls its
|
|
469
571
|
// register(addRoute) export. Extensions can add routes or shadow core routes.
|
|
@@ -518,9 +620,12 @@ if (isMainModule && !process.env.DAVE_REPO_ROOT && process.env.WS_STATUS_MON !==
|
|
|
518
620
|
if (isMainModule) (async function () {
|
|
519
621
|
await loadExtensions();
|
|
520
622
|
|
|
521
|
-
server.listen(port, () => {
|
|
623
|
+
server.listen(port, host, () => {
|
|
522
624
|
const url = 'http://localhost:' + port;
|
|
523
|
-
|
|
625
|
+
if (!IS_LOOPBACK_BIND) {
|
|
626
|
+
warnAlways('[workspace] WARNING: bound to ' + host + ':' + port + ' — reachable from other hosts. Set WORKSPACE_HOST=127.0.0.1 or omit --host to restore loopback-only.');
|
|
627
|
+
}
|
|
628
|
+
log('[workspace] Dave Workspace running at ' + url + ' (bind ' + host + ')');
|
|
524
629
|
log('[workspace] Serving from: ' + REPO_ROOT);
|
|
525
630
|
if (EXT_DIR) log('[workspace] Extensions: ' + EXT_DIR.replace(REPO_ROOT + '/', ''));
|
|
526
631
|
log('[workspace] Idle timeout: ' + (noTimeout ? 'disabled (persistent)' : timeoutMinutes + ' minutes'));
|
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
* Manages the workspace dev server (server.mjs) as a background process.
|
|
7
7
|
*
|
|
8
8
|
* Usage:
|
|
9
|
-
* node ws.mjs start [--port N] [--no-open] Start server as background daemon
|
|
10
|
-
* node ws.mjs stop
|
|
11
|
-
* node ws.mjs restart [--port N] [--no-open] Stop then start
|
|
9
|
+
* node ws.mjs start [--port N] [--host H] [--no-open] Start server as background daemon
|
|
10
|
+
* node ws.mjs stop Stop the running server
|
|
11
|
+
* node ws.mjs restart [--port N] [--host H] [--no-open] Stop then start
|
|
12
12
|
* node ws.mjs status Show server status
|
|
13
13
|
* node ws.mjs open Open browser to server URL
|
|
14
14
|
*/
|
|
@@ -330,6 +330,14 @@ async function cmdStart() {
|
|
|
330
330
|
serverArgs.push('--port', String(port));
|
|
331
331
|
serverArgs.push('--no-open'); // CLI handles browser open, not server
|
|
332
332
|
|
|
333
|
+
// Bind host pass-through. Loopback is the server-side default; the CLI
|
|
334
|
+
// only forwards --host when the operator has explicitly asked for a
|
|
335
|
+
// wider bind, so an unadorned `pa workspace start` stays loopback-only.
|
|
336
|
+
const hostFlag = getFlag('--host');
|
|
337
|
+
if (hostFlag) {
|
|
338
|
+
serverArgs.push('--host', hostFlag);
|
|
339
|
+
}
|
|
340
|
+
|
|
333
341
|
// Open log file for stdout/stderr
|
|
334
342
|
const { openSync, close } = await import('node:fs');
|
|
335
343
|
const logFd = openSync(LOG_FILE, 'w');
|
|
@@ -585,11 +593,15 @@ export { isProcessAlive, checkPort, findPortHolder };
|
|
|
585
593
|
const USAGE = `Usage: node ws.mjs <command> [options]
|
|
586
594
|
|
|
587
595
|
Commands:
|
|
588
|
-
start [--port N] [--no-open] Start workspace server
|
|
589
|
-
stop
|
|
590
|
-
restart [--port N] [--no-open] Restart workspace server
|
|
591
|
-
status
|
|
592
|
-
open
|
|
596
|
+
start [--port N] [--host H] [--no-open] Start workspace server
|
|
597
|
+
stop Stop workspace server
|
|
598
|
+
restart [--port N] [--host H] [--no-open] Restart workspace server
|
|
599
|
+
status Show server status
|
|
600
|
+
open Open browser to server URL
|
|
601
|
+
|
|
602
|
+
Bind host defaults to loopback (127.0.0.1). Pass --host <addr> or set
|
|
603
|
+
WORKSPACE_HOST to expose the workspace on another interface — a non-loopback
|
|
604
|
+
bind is a deliberate opt-in and the server prints a warning at startup.`;
|
|
593
605
|
|
|
594
606
|
// Only dispatch the CLI when this file is the entrypoint. When imported as a
|
|
595
607
|
// module (e.g. by tests that pull in exported helpers), skip the switch so
|
package/package.json
CHANGED