brane 0.1.1 → 0.3.0
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/AGENTS.md +362 -0
- package/CHANGELOG.md +33 -0
- package/CONTRIBUTING.md +91 -0
- package/DESIGN.md +402 -0
- package/Dockerfile +61 -0
- package/LICENSE +656 -17
- package/README.md +174 -2
- package/SECURITY.md +205 -0
- package/dist/absorb/brane-id.js +11 -0
- package/dist/absorb/chatgpt.js +161 -0
- package/dist/absorb/claude-code.js +504 -0
- package/dist/absorb/index.js +543 -0
- package/dist/absorb/organelle.js +345 -0
- package/dist/absorb/run.js +520 -0
- package/dist/absorb/sources.js +168 -0
- package/dist/absorb/types.js +3 -0
- package/dist/agent-env.js +45 -0
- package/dist/agents.js +1168 -0
- package/dist/claude-live-session.js +124 -0
- package/dist/cli.js +50 -0
- package/dist/codex-tool-log.js +144 -0
- package/dist/commands/absorb.js +291 -0
- package/dist/commands/attach.js +31 -0
- package/dist/commands/certify.js +62 -0
- package/dist/commands/config.js +116 -0
- package/dist/commands/cost.js +126 -0
- package/dist/commands/daemon.js +350 -0
- package/dist/commands/doctor.js +262 -0
- package/dist/commands/fold.js +95 -0
- package/dist/commands/list.js +89 -0
- package/dist/commands/mcp.js +84 -0
- package/dist/commands/meld.js +332 -0
- package/dist/commands/memory.js +215 -0
- package/dist/commands/revive.js +74 -0
- package/dist/commands/run.js +85 -0
- package/dist/commands/spawn.js +1094 -0
- package/dist/commands/token.js +153 -0
- package/dist/copilot-tool-log.js +145 -0
- package/dist/cost/pricing.js +116 -0
- package/dist/cost/reprice.js +136 -0
- package/dist/crush-tool-log.js +293 -0
- package/dist/daemon/agent-scope.js +116 -0
- package/dist/daemon/awaiting.js +54 -0
- package/dist/daemon/cert-manager.js +349 -0
- package/dist/daemon/client.js +262 -0
- package/dist/daemon/gated-routes.js +112 -0
- package/dist/daemon/keep-awake.js +103 -0
- package/dist/daemon/local-socket.js +145 -0
- package/dist/daemon/presence-gate.js +206 -0
- package/dist/daemon/server.js +6349 -0
- package/dist/daemon/signal-source.js +35 -0
- package/dist/daemon/tailscale-rebind.js +58 -0
- package/dist/daemon/token-ring.js +263 -0
- package/dist/daemon/token-routes.js +190 -0
- package/dist/db/events.js +152 -0
- package/dist/db/mentions.js +179 -0
- package/dist/db/schema.js +633 -0
- package/dist/db/tokens.js +583 -0
- package/dist/db/tool-uses.js +647 -0
- package/dist/diagram/index.js +85 -0
- package/dist/diagram/label.js +455 -0
- package/dist/diagram/layout.js +476 -0
- package/dist/diagram/links.js +50 -0
- package/dist/diagram/metrics.js +152 -0
- package/dist/diagram/parse.js +487 -0
- package/dist/diagram/svg.js +389 -0
- package/dist/diagram/types.js +9 -0
- package/dist/export.js +629 -0
- package/dist/files/archive-types.js +2 -0
- package/dist/files/archive.js +182 -0
- package/dist/files/browse.js +327 -0
- package/dist/files/bytes.js +162 -0
- package/dist/files/codecs.js +33 -0
- package/dist/files/find.js +212 -0
- package/dist/files/tar.js +223 -0
- package/dist/files/zip.js +209 -0
- package/dist/folds.js +72 -0
- package/dist/grammars/ebnf.js +92 -0
- package/dist/highlight.js +250 -0
- package/dist/ids.js +24 -0
- package/dist/markdown-diagnostics.js +233 -0
- package/dist/math.js +168 -0
- package/dist/mcp/server.js +886 -0
- package/dist/memory/embedder.js +145 -0
- package/dist/memory/embedding-worker.js +71 -0
- package/dist/memory/embeddings.js +91 -0
- package/dist/memory/index.js +232 -0
- package/dist/memory/storage.js +587 -0
- package/dist/memory/wiki.js +688 -0
- package/dist/memory/wordpiece.js +247 -0
- package/dist/mentions-scan.js +40 -0
- package/dist/presence.js +259 -0
- package/dist/session-affinity.js +185 -0
- package/dist/shared/api-request.js +124 -0
- package/dist/shared/block-glyphs.js +114 -0
- package/dist/shared/diagram-css.js +17 -0
- package/dist/shared/export-selection.js +101 -0
- package/dist/shared/find-in-text.js +110 -0
- package/dist/shared/glow-hues.js +225 -0
- package/dist/shared/hex-dump.js +62 -0
- package/dist/shared/hex-pages.js +52 -0
- package/dist/shared/hit-drum.js +79 -0
- package/dist/shared/known-nids.js +58 -0
- package/dist/shared/list-scroll-keys.js +56 -0
- package/dist/shared/markdown-lists.js +72 -0
- package/dist/shared/markdown-math.js +485 -0
- package/dist/shared/markdown-mentions.js +79 -0
- package/dist/shared/markdown-nids.js +41 -0
- package/dist/shared/markdown-protocols.js +53 -0
- package/dist/shared/markdown-refs.js +123 -0
- package/dist/shared/membrane-invasion.js +342 -0
- package/dist/shared/membrane-motion.js +83 -0
- package/dist/shared/membrane-palette.js +253 -0
- package/dist/shared/membrane-terrain.js +212 -0
- package/dist/shared/message-selection.js +123 -0
- package/dist/shared/mo-glyph.js +57 -0
- package/dist/shared/narrative-format.js +237 -0
- package/dist/shared/narrative-menu.js +82 -0
- package/dist/shared/narrative-sort.js +47 -0
- package/dist/shared/narrative-status.js +52 -0
- package/dist/shared/no-pan.js +85 -0
- package/dist/shared/pane-layout.js +50 -0
- package/dist/shared/payload-inline.js +83 -0
- package/dist/shared/speaker-color.js +61 -0
- package/dist/shared/token-rotation.js +14 -0
- package/dist/shared/view-url.js +197 -0
- package/dist/shared/wiki-links.js +70 -0
- package/dist/shared/wiki-window.js +4 -0
- package/dist/tailscale.js +55 -0
- package/dist/test/absorb-claude.test.js +892 -0
- package/dist/test/agent-scope.test.js +193 -0
- package/dist/test/agents.test.js +55 -0
- package/dist/test/api-request.test.js +122 -0
- package/dist/test/awaiting-glow.test.js +54 -0
- package/dist/test/awaiting.test.js +122 -0
- package/dist/test/bind-token.test.js +88 -0
- package/dist/test/block-glyphs.test.js +68 -0
- package/dist/test/cert-manager.test.js +262 -0
- package/dist/test/claude-live-session.test.js +110 -0
- package/dist/test/codex-tool-log.test.js +568 -0
- package/dist/test/config.test.js +71 -0
- package/dist/test/copilot-tool-log.test.js +93 -0
- package/dist/test/cors-preflight.test.js +49 -0
- package/dist/test/cost-linger.test.js +31 -0
- package/dist/test/cost-pricing.test.js +72 -0
- package/dist/test/cost-reprice.test.js +139 -0
- package/dist/test/cost-sessions.test.js +301 -0
- package/dist/test/crush-tool-log.test.js +289 -0
- package/dist/test/daemon-client.test.js +109 -0
- package/dist/test/daemon-socket.test.js +293 -0
- package/dist/test/diagram-arrows.test.js +205 -0
- package/dist/test/diagram-export.test.js +210 -0
- package/dist/test/diagram-labels.test.js +313 -0
- package/dist/test/diagram-parse.test.js +191 -0
- package/dist/test/diagram-render.test.js +175 -0
- package/dist/test/diagram-review.test.js +288 -0
- package/dist/test/diagram-self-loop-labels.test.js +303 -0
- package/dist/test/embedder.test.js +84 -0
- package/dist/test/events.test.js +123 -0
- package/dist/test/export-selection.test.js +102 -0
- package/dist/test/export.test.js +505 -0
- package/dist/test/files-browse.test.js +393 -0
- package/dist/test/files-find.test.js +272 -0
- package/dist/test/find-in-text.test.js +86 -0
- package/dist/test/first-paint-viewport.test.js +59 -0
- package/dist/test/fixtures/embedding-golden.js +23 -0
- package/dist/test/fixtures/wordpiece-golden.js +167 -0
- package/dist/test/glow-hues.test.js +202 -0
- package/dist/test/harness-cost.test.js +423 -0
- package/dist/test/helpers.js +21 -0
- package/dist/test/hex-pages.test.js +68 -0
- package/dist/test/highlight.test.js +69 -0
- package/dist/test/hit-drum.test.js +71 -0
- package/dist/test/hook-config.test.js +262 -0
- package/dist/test/json-gzip.test.js +70 -0
- package/dist/test/key-routing.test.js +58 -0
- package/dist/test/known-nids.test.js +106 -0
- package/dist/test/link-preview.test.js +67 -0
- package/dist/test/list-scroll-keys.test.js +66 -0
- package/dist/test/listen-own-words.test.js +147 -0
- package/dist/test/listen-timeout.test.js +33 -0
- package/dist/test/markdown-diagnostics.test.js +121 -0
- package/dist/test/markdown-lists.test.js +182 -0
- package/dist/test/markdown-math.test.js +256 -0
- package/dist/test/markdown-nids.test.js +146 -0
- package/dist/test/markdown-protocols.test.js +139 -0
- package/dist/test/mcp-bridge-cancel.test.js +98 -0
- package/dist/test/mcp-bridge-reconnect.test.js +121 -0
- package/dist/test/mcp-narrative.test.js +193 -0
- package/dist/test/mcp.test.js +548 -0
- package/dist/test/meld.test.js +85 -0
- package/dist/test/membrane-invasion.test.js +482 -0
- package/dist/test/membrane-motion.test.js +101 -0
- package/dist/test/membrane-palette.test.js +166 -0
- package/dist/test/membrane-terrain.test.js +170 -0
- package/dist/test/memory-log.test.js +202 -0
- package/dist/test/mentions-e2e.test.js +261 -0
- package/dist/test/mentions-ledger.test.js +265 -0
- package/dist/test/mentions-listen.test.js +265 -0
- package/dist/test/mentions.test.js +238 -0
- package/dist/test/message-selection.test.js +114 -0
- package/dist/test/mo-glyph.test.js +22 -0
- package/dist/test/model-resolution.test.js +166 -0
- package/dist/test/narrate-validate.test.js +77 -0
- package/dist/test/narrate-wait.test.js +160 -0
- package/dist/test/narrative-format.test.js +219 -0
- package/dist/test/narrative-menu.test.js +137 -0
- package/dist/test/narrative-sort.test.js +75 -0
- package/dist/test/no-pan.test.js +60 -0
- package/dist/test/pane-layout.test.js +153 -0
- package/dist/test/paths.test.js +37 -0
- package/dist/test/payload-inline.test.js +91 -0
- package/dist/test/precache-fonts.test.js +72 -0
- package/dist/test/presence-gate.test.js +440 -0
- package/dist/test/presence.test.js +160 -0
- package/dist/test/read-events.test.js +220 -0
- package/dist/test/remote-meld.test.js +115 -0
- package/dist/test/revive-cli.test.js +93 -0
- package/dist/test/search-floor.test.js +206 -0
- package/dist/test/search-fusion.test.js +168 -0
- package/dist/test/search.test.js +205 -0
- package/dist/test/security.test.js +48 -0
- package/dist/test/selection-wiring.test.js +61 -0
- package/dist/test/session-affinity.test.js +295 -0
- package/dist/test/signal-source.test.js +32 -0
- package/dist/test/socket-account.test.js +268 -0
- package/dist/test/spawn-toml.test.js +72 -0
- package/dist/test/speaker-color.test.js +90 -0
- package/dist/test/tailscale-rebind.test.js +79 -0
- package/dist/test/terminal-cursor.test.js +61 -0
- package/dist/test/terminal-resync.test.js +23 -0
- package/dist/test/thin-cli.test.js +382 -0
- package/dist/test/token-bootstrap.test.js +272 -0
- package/dist/test/token-devices.test.js +587 -0
- package/dist/test/token-lapse.test.js +94 -0
- package/dist/test/token-ring.test.js +188 -0
- package/dist/test/token-rotate-cli.test.js +58 -0
- package/dist/test/token-routes.test.js +95 -0
- package/dist/test/tool-uses.test.js +1006 -0
- package/dist/test/usage-ledger.test.js +126 -0
- package/dist/test/view-url.test.js +581 -0
- package/dist/test/wiki-links.test.js +57 -0
- package/dist/test/wiki-sources.test.js +85 -0
- package/dist/test/wiki-window.test.js +31 -0
- package/dist/test/wiki.test.js +399 -0
- package/dist/test/wordpiece.test.js +68 -0
- package/dist/tls.js +148 -0
- package/dist/tmux.js +202 -0
- package/dist/user-agent.js +46 -0
- package/docker-entrypoint.sh +141 -0
- package/docs/README.md +3 -0
- package/docs/absorb.md +231 -0
- package/docs/cost-ledger.md +125 -0
- package/docs/daemon-api.md +189 -0
- package/docs/diagrams.md +163 -0
- package/docs/doctor.md +36 -0
- package/docs/export-scope.md +69 -0
- package/docs/files-view.md +133 -0
- package/docs/link-previews.md +38 -0
- package/docs/math.md +32 -0
- package/docs/mcp.md +69 -0
- package/docs/memory-search.md +75 -0
- package/docs/mentions.md +101 -0
- package/docs/multi-brane.md +14 -0
- package/docs/passkeys.md +165 -0
- package/docs/presence.md +202 -0
- package/docs/releasing.md +53 -0
- package/docs/rendering-warnings.md +22 -0
- package/docs/request-layer.md +72 -0
- package/docs/selection-store.md +50 -0
- package/docs/session-affinity.md +61 -0
- package/docs/terminal-mirroring.md +211 -0
- package/docs/token-rotation.md +413 -0
- package/docs/view-urls.md +114 -0
- package/docs/web-app.md +23 -0
- package/docs/wiki.md +43 -0
- package/expo/dist/_expo/static/js/web/entry-e56efc1291dca7258ea00b8108b033a4.js +1097 -0
- package/expo/dist/apple-touch-icon.png +0 -0
- package/expo/dist/favicon.svg +3 -0
- package/expo/dist/index.html +177 -0
- package/expo/dist/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
- package/expo/dist/katex/katex.min.css +1 -0
- package/expo/dist/manifest.json +16 -0
- package/expo/dist/metadata.json +1 -0
- package/expo/dist/og/membrane.v1.png +0 -0
- package/expo/dist/pdf.min.mjs +28 -0
- package/expo/dist/pdf.worker.min.mjs +28 -0
- package/expo/dist/sw.js +154 -0
- package/package.json +79 -27
- package/protocols/LIVE_loop.md +407 -0
- package/protocols/bebrief.md +41 -0
- package/protocols/digest.md +231 -0
- package/protocols/fly.md +266 -0
- package/protocols/idle.md +81 -0
- package/protocols/keeper.md +829 -0
- package/protocols/markdown.md +80 -0
- package/protocols/memory.md +419 -0
- package/protocols/mentions.md +93 -0
- package/protocols/payloads.md +53 -0
- package/protocols/prelude.md +91 -0
- package/protocols/ramify.md +105 -0
- package/protocols/review.md +356 -0
- package/protocols/rrr.md +232 -0
- package/protocols/scribe.md +429 -0
- package/protocols/summarize.md +203 -0
- package/protocols/twin.md +174 -0
- package/protocols/wiki.md +438 -0
- package/protocols/worker.md +38 -0
- package/wiki/shared/append-only-event-log.md +148 -0
- package/wiki/shared/index.md +48 -0
- package/wiki/shared/owning-your-narratives.md +161 -0
- package/wiki/shared/premature-ontologies.md +435 -0
- package/wiki/shared/sedimentary-superintelligence.md +128 -0
- package/wiki/shared/sequestering-the-narrative.md +143 -0
- package/wiki/shared/why-wikis-work.md +216 -0
- package/.travis.yml +0 -7
- package/lib/brane.cjs.js +0 -12
- package/lib/brane.cjs.js.map +0 -1
- package/lib/brane.d.ts +0 -11
- package/lib/brane.js +0 -6
- package/lib/brane.js.map +0 -1
- package/lib/tests.d.ts +0 -1
- package/lib/tests.js +0 -8
- package/lib/tests.js.map +0 -1
- package/rollup.config.js +0 -21
- package/scripts/test.sh +0 -8
- package/src/brane.ts +0 -21
- package/src/tests.ts +0 -8
- package/tsconfig.json +0 -16
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { mkdtempSync, readFileSync, writeFileSync, mkdirSync, existsSync, statSync, chmodSync } from "node:fs";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { dirname, join } from "node:path";
|
|
7
|
+
import { withSessionAffinity, shellAffinityEnv, isFireworksBaseUrl, claudeSessionAffinityPath, ensureClaudeSessionAffinity, ensureSessionAffinityConfig, } from "../session-affinity.js";
|
|
8
|
+
const FIREWORKS = "https://api.fireworks.ai/inference";
|
|
9
|
+
function tempDir(prefix) {
|
|
10
|
+
return mkdtempSync(join(tmpdir(), prefix));
|
|
11
|
+
}
|
|
12
|
+
function writeJson(path, value) {
|
|
13
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
14
|
+
writeFileSync(path, JSON.stringify(value, null, 2) + "\n");
|
|
15
|
+
}
|
|
16
|
+
function readJson(path) {
|
|
17
|
+
return JSON.parse(readFileSync(path, "utf-8"));
|
|
18
|
+
}
|
|
19
|
+
function affinityFileHeaders(path) {
|
|
20
|
+
const config = readJson(path);
|
|
21
|
+
return config.env?.ANTHROPIC_CUSTOM_HEADERS;
|
|
22
|
+
}
|
|
23
|
+
/** Run a tmux-style env prefix through POSIX sh, the way tmux runs the
|
|
24
|
+
* session command, and report what the child sees. */
|
|
25
|
+
function headersUnderShell(prefix, inherited) {
|
|
26
|
+
const env = { ...process.env };
|
|
27
|
+
if (inherited === undefined)
|
|
28
|
+
delete env.ANTHROPIC_CUSTOM_HEADERS;
|
|
29
|
+
else
|
|
30
|
+
env.ANTHROPIC_CUSTOM_HEADERS = inherited;
|
|
31
|
+
const out = spawnSync("/bin/sh", ["-c", `${prefix} node -e 'process.stdout.write(process.env.ANTHROPIC_CUSTOM_HEADERS)'`], {
|
|
32
|
+
env,
|
|
33
|
+
encoding: "utf-8",
|
|
34
|
+
});
|
|
35
|
+
assert.equal(out.status, 0, out.stderr);
|
|
36
|
+
return out.stdout;
|
|
37
|
+
}
|
|
38
|
+
/** Pin every env var the implementation consults, so a host that exports
|
|
39
|
+
* CLAUDE_CONFIG_DIR, ANTHROPIC_BASE_URL, or ANTHROPIC_CUSTOM_HEADERS can't
|
|
40
|
+
* leak into the assertions. */
|
|
41
|
+
function withCleanEnv(fn) {
|
|
42
|
+
const keys = ["CLAUDE_CONFIG_DIR", "ANTHROPIC_BASE_URL", "ANTHROPIC_CUSTOM_HEADERS"];
|
|
43
|
+
const saved = Object.fromEntries(keys.map((k) => [k, process.env[k]]));
|
|
44
|
+
for (const k of keys)
|
|
45
|
+
delete process.env[k];
|
|
46
|
+
try {
|
|
47
|
+
fn();
|
|
48
|
+
}
|
|
49
|
+
finally {
|
|
50
|
+
for (const [key, value] of Object.entries(saved)) {
|
|
51
|
+
if (value === undefined)
|
|
52
|
+
delete process.env[key];
|
|
53
|
+
else
|
|
54
|
+
process.env[key] = value;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function dirs() {
|
|
59
|
+
return {
|
|
60
|
+
fold: tempDir("brane-affinity-fold-"),
|
|
61
|
+
home: tempDir("brane-affinity-home-"),
|
|
62
|
+
braneDir: tempDir("brane-affinity-dir-"),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
describe("withSessionAffinity", () => {
|
|
66
|
+
it("appends to an empty or absent blob", () => {
|
|
67
|
+
assert.equal(withSessionAffinity(undefined, "claude-abc123"), "X-Session-Affinity: claude-abc123");
|
|
68
|
+
assert.equal(withSessionAffinity("", "claude-abc123"), "X-Session-Affinity: claude-abc123");
|
|
69
|
+
});
|
|
70
|
+
it("preserves unrelated lines verbatim and in order", () => {
|
|
71
|
+
const existing = "Authorization: Bearer fw-key-123\nX-Other: keepme";
|
|
72
|
+
assert.equal(withSessionAffinity(existing, "claude-abc123"), "Authorization: Bearer fw-key-123\nX-Other: keepme\nX-Session-Affinity: claude-abc123");
|
|
73
|
+
});
|
|
74
|
+
it("replaces a prior affinity line rather than accumulating", () => {
|
|
75
|
+
const first = withSessionAffinity("Authorization: Bearer k", "claude-aaa111");
|
|
76
|
+
const second = withSessionAffinity(first, "claude-bbb222");
|
|
77
|
+
assert.equal(second, "Authorization: Bearer k\nX-Session-Affinity: claude-bbb222");
|
|
78
|
+
});
|
|
79
|
+
it("matches the header name case-insensitively, the way Claude Code parses", () => {
|
|
80
|
+
const out = withSessionAffinity("x-session-affinity: stale\nAuthorization: Bearer k", "claude-abc123");
|
|
81
|
+
assert.equal(out, "Authorization: Bearer k\nX-Session-Affinity: claude-abc123");
|
|
82
|
+
});
|
|
83
|
+
it("tolerates CRLF input without emitting CR", () => {
|
|
84
|
+
const out = withSessionAffinity("Authorization: Bearer k\r\nX-Other: v", "claude-abc123");
|
|
85
|
+
assert.equal(out, "Authorization: Bearer k\nX-Other: v\nX-Session-Affinity: claude-abc123");
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
describe("isFireworksBaseUrl", () => {
|
|
89
|
+
it("matches fireworks.ai hosts only", () => {
|
|
90
|
+
assert.equal(isFireworksBaseUrl("https://api.fireworks.ai/inference"), true);
|
|
91
|
+
assert.equal(isFireworksBaseUrl("https://fireworks.ai"), true);
|
|
92
|
+
assert.equal(isFireworksBaseUrl("https://api.anthropic.com"), false);
|
|
93
|
+
assert.equal(isFireworksBaseUrl("https://proxy.example.com/fireworks.ai/v1"), false);
|
|
94
|
+
assert.equal(isFireworksBaseUrl("https://notfireworks.ai"), false);
|
|
95
|
+
assert.equal(isFireworksBaseUrl("not a url"), false);
|
|
96
|
+
assert.equal(isFireworksBaseUrl(undefined), false);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
describe("shellAffinityEnv", () => {
|
|
100
|
+
it("appends to the headers the session's shell inherited", () => {
|
|
101
|
+
assert.equal(headersUnderShell(shellAffinityEnv("claude-sh1"), "Authorization: Bearer shell-only"), "Authorization: Bearer shell-only\nX-Session-Affinity: claude-sh1");
|
|
102
|
+
});
|
|
103
|
+
it("adds no leading newline when nothing was inherited", () => {
|
|
104
|
+
assert.equal(headersUnderShell(shellAffinityEnv("claude-sh2"), undefined), "X-Session-Affinity: claude-sh2");
|
|
105
|
+
assert.equal(headersUnderShell(shellAffinityEnv("claude-sh3"), ""), "X-Session-Affinity: claude-sh3");
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
describe("ensureClaudeSessionAffinity", () => {
|
|
109
|
+
it("does nothing when the session does not talk to Fireworks", () => {
|
|
110
|
+
const { fold, home, braneDir } = dirs();
|
|
111
|
+
writeJson(join(fold, ".claude", "settings.local.json"), {
|
|
112
|
+
env: { ANTHROPIC_CUSTOM_HEADERS: "Authorization: Bearer k" },
|
|
113
|
+
});
|
|
114
|
+
withCleanEnv(() => {
|
|
115
|
+
assert.deepEqual(ensureClaudeSessionAffinity(fold, "claude-anth01", { homeDir: home, braneDir }), {
|
|
116
|
+
args: [],
|
|
117
|
+
env: "",
|
|
118
|
+
});
|
|
119
|
+
process.env.ANTHROPIC_BASE_URL = "https://api.anthropic.com";
|
|
120
|
+
assert.deepEqual(ensureClaudeSessionAffinity(fold, "claude-anth01", { homeDir: home, braneDir }), {
|
|
121
|
+
args: [],
|
|
122
|
+
env: "",
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
assert.equal(existsSync(join(braneDir, "session-affinity")), false);
|
|
126
|
+
});
|
|
127
|
+
it("uses the shell prefix, not a file, when no settings file defines the headers", () => {
|
|
128
|
+
const { fold, home, braneDir } = dirs();
|
|
129
|
+
writeJson(join(fold, ".claude", "settings.local.json"), { env: { ANTHROPIC_BASE_URL: FIREWORKS } });
|
|
130
|
+
withCleanEnv(() => {
|
|
131
|
+
// A daemon-side value must not be pinned: the session inherits from tmux.
|
|
132
|
+
process.env.ANTHROPIC_CUSTOM_HEADERS = "X-Daemon-Only: yes";
|
|
133
|
+
const launch = ensureClaudeSessionAffinity(fold, "claude-shell1", { homeDir: home, braneDir });
|
|
134
|
+
assert.deepEqual(launch, { args: [], env: shellAffinityEnv("claude-shell1") });
|
|
135
|
+
});
|
|
136
|
+
assert.equal(existsSync(join(braneDir, "session-affinity")), false);
|
|
137
|
+
});
|
|
138
|
+
it("takes the Fireworks base URL from the daemon env when no settings file sets one", () => {
|
|
139
|
+
const { fold, home, braneDir } = dirs();
|
|
140
|
+
withCleanEnv(() => {
|
|
141
|
+
process.env.ANTHROPIC_BASE_URL = FIREWORKS;
|
|
142
|
+
const launch = ensureClaudeSessionAffinity(fold, "claude-envurl", { homeDir: home, braneDir });
|
|
143
|
+
assert.equal(launch.env, shellAffinityEnv("claude-envurl"));
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
it("writes a private per-narrative --settings file when a settings file defines the headers", () => {
|
|
147
|
+
const { fold, home, braneDir } = dirs();
|
|
148
|
+
writeJson(join(fold, ".claude", "settings.local.json"), {
|
|
149
|
+
env: { ANTHROPIC_BASE_URL: FIREWORKS, ANTHROPIC_CUSTOM_HEADERS: "Authorization: Bearer fw-secret" },
|
|
150
|
+
});
|
|
151
|
+
withCleanEnv(() => {
|
|
152
|
+
const launch = ensureClaudeSessionAffinity(fold, "claude-new123", { homeDir: home, braneDir });
|
|
153
|
+
const path = join(braneDir, "session-affinity", "claude-new123.json");
|
|
154
|
+
assert.deepEqual(launch, { args: ["--settings", path], env: "" });
|
|
155
|
+
assert.equal(affinityFileHeaders(path), "Authorization: Bearer fw-secret\nX-Session-Affinity: claude-new123");
|
|
156
|
+
assert.equal(statSync(path).mode & 0o777, 0o600);
|
|
157
|
+
assert.equal(statSync(dirname(path)).mode & 0o777, 0o700);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
it("tightens a file and directory written with looser permissions", () => {
|
|
161
|
+
const { fold, home, braneDir } = dirs();
|
|
162
|
+
writeJson(join(fold, ".claude", "settings.local.json"), {
|
|
163
|
+
env: { ANTHROPIC_BASE_URL: FIREWORKS, ANTHROPIC_CUSTOM_HEADERS: "Authorization: Bearer k" },
|
|
164
|
+
});
|
|
165
|
+
const path = claudeSessionAffinityPath("claude-loose1", { braneDir });
|
|
166
|
+
withCleanEnv(() => {
|
|
167
|
+
ensureClaudeSessionAffinity(fold, "claude-loose1", { homeDir: home, braneDir });
|
|
168
|
+
chmodSync(path, 0o644);
|
|
169
|
+
chmodSync(dirname(path), 0o755);
|
|
170
|
+
ensureClaudeSessionAffinity(fold, "claude-loose1", { homeDir: home, braneDir });
|
|
171
|
+
});
|
|
172
|
+
assert.equal(statSync(path).mode & 0o777, 0o600);
|
|
173
|
+
assert.equal(statSync(dirname(path)).mode & 0o777, 0o700);
|
|
174
|
+
});
|
|
175
|
+
it("gives concurrent same-fold narratives distinct files and leaves the fold file alone", () => {
|
|
176
|
+
const { fold, home, braneDir } = dirs();
|
|
177
|
+
const localPath = join(fold, ".claude", "settings.local.json");
|
|
178
|
+
writeJson(localPath, {
|
|
179
|
+
env: { ANTHROPIC_BASE_URL: FIREWORKS, ANTHROPIC_CUSTOM_HEADERS: "Authorization: Bearer fw-secret" },
|
|
180
|
+
});
|
|
181
|
+
const before = readFileSync(localPath, "utf-8");
|
|
182
|
+
withCleanEnv(() => {
|
|
183
|
+
const a = ensureClaudeSessionAffinity(fold, "claude-aaa111", { homeDir: home, braneDir }).args[1];
|
|
184
|
+
const b = ensureClaudeSessionAffinity(fold, "claude-bbb222", { homeDir: home, braneDir }).args[1];
|
|
185
|
+
assert.notEqual(a, b);
|
|
186
|
+
assert.equal(affinityFileHeaders(a), "Authorization: Bearer fw-secret\nX-Session-Affinity: claude-aaa111");
|
|
187
|
+
assert.equal(affinityFileHeaders(b), "Authorization: Bearer fw-secret\nX-Session-Affinity: claude-bbb222");
|
|
188
|
+
});
|
|
189
|
+
assert.equal(readFileSync(localPath, "utf-8"), before);
|
|
190
|
+
});
|
|
191
|
+
it("prefers local over project over user, for both variables independently", () => {
|
|
192
|
+
const { fold, home, braneDir } = dirs();
|
|
193
|
+
writeJson(join(home, ".claude", "settings.json"), {
|
|
194
|
+
env: { ANTHROPIC_BASE_URL: FIREWORKS, ANTHROPIC_CUSTOM_HEADERS: "X-Source: user" },
|
|
195
|
+
});
|
|
196
|
+
writeJson(join(fold, ".claude", "settings.json"), { env: { ANTHROPIC_CUSTOM_HEADERS: "X-Source: project" } });
|
|
197
|
+
withCleanEnv(() => {
|
|
198
|
+
// Base URL from user settings, headers from project: project beats user.
|
|
199
|
+
let path = ensureClaudeSessionAffinity(fold, "claude-src111", { homeDir: home, braneDir }).args[1];
|
|
200
|
+
assert.match(affinityFileHeaders(path), /^X-Source: project\n/);
|
|
201
|
+
writeJson(join(fold, ".claude", "settings.local.json"), { env: { ANTHROPIC_CUSTOM_HEADERS: "X-Source: local" } });
|
|
202
|
+
path = ensureClaudeSessionAffinity(fold, "claude-src111", { homeDir: home, braneDir }).args[1];
|
|
203
|
+
assert.match(affinityFileHeaders(path), /^X-Source: local\n/);
|
|
204
|
+
// A higher-precedence non-Fireworks base URL turns it off.
|
|
205
|
+
writeJson(join(fold, ".claude", "settings.local.json"), {
|
|
206
|
+
env: { ANTHROPIC_BASE_URL: "https://api.anthropic.com", ANTHROPIC_CUSTOM_HEADERS: "X-Source: local" },
|
|
207
|
+
});
|
|
208
|
+
assert.deepEqual(ensureClaudeSessionAffinity(fold, "claude-src111", { homeDir: home, braneDir }).args, []);
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
it("treats a broken candidate file as absent and never writes to it", () => {
|
|
212
|
+
const { fold, home, braneDir } = dirs();
|
|
213
|
+
const localPath = join(fold, ".claude", "settings.local.json");
|
|
214
|
+
mkdirSync(dirname(localPath), { recursive: true });
|
|
215
|
+
writeFileSync(localPath, "{ not json — operator will fix by hand");
|
|
216
|
+
writeJson(join(fold, ".claude", "settings.json"), {
|
|
217
|
+
env: { ANTHROPIC_BASE_URL: FIREWORKS, ANTHROPIC_CUSTOM_HEADERS: "X-Source: project" },
|
|
218
|
+
});
|
|
219
|
+
withCleanEnv(() => {
|
|
220
|
+
const path = ensureClaudeSessionAffinity(fold, "claude-nope00", { homeDir: home, braneDir }).args[1];
|
|
221
|
+
assert.equal(affinityFileHeaders(path), "X-Source: project\nX-Session-Affinity: claude-nope00");
|
|
222
|
+
});
|
|
223
|
+
assert.equal(readFileSync(localPath, "utf-8"), "{ not json — operator will fix by hand");
|
|
224
|
+
});
|
|
225
|
+
it("is idempotent: a second identical call does not rewrite the file", () => {
|
|
226
|
+
const { fold, home, braneDir } = dirs();
|
|
227
|
+
writeJson(join(fold, ".claude", "settings.local.json"), {
|
|
228
|
+
env: { ANTHROPIC_BASE_URL: FIREWORKS, ANTHROPIC_CUSTOM_HEADERS: "Authorization: Bearer k" },
|
|
229
|
+
});
|
|
230
|
+
const path = claudeSessionAffinityPath("claude-same1", { braneDir });
|
|
231
|
+
withCleanEnv(() => ensureClaudeSessionAffinity(fold, "claude-same1", { homeDir: home, braneDir }));
|
|
232
|
+
const before = statSync(path).mtimeMs;
|
|
233
|
+
withCleanEnv(() => ensureClaudeSessionAffinity(fold, "claude-same1", { homeDir: home, braneDir }));
|
|
234
|
+
assert.equal(statSync(path).mtimeMs, before);
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
describe("ensureSessionAffinityConfig", () => {
|
|
238
|
+
function fireworksFold() {
|
|
239
|
+
const d = dirs();
|
|
240
|
+
writeJson(join(d.fold, ".claude", "settings.local.json"), {
|
|
241
|
+
env: { ANTHROPIC_BASE_URL: FIREWORKS, ANTHROPIC_CUSTOM_HEADERS: "Authorization: Bearer k" },
|
|
242
|
+
});
|
|
243
|
+
return d;
|
|
244
|
+
}
|
|
245
|
+
it("returns claude's --settings args", () => {
|
|
246
|
+
const { fold, home, braneDir } = fireworksFold();
|
|
247
|
+
withCleanEnv(() => {
|
|
248
|
+
const launch = ensureSessionAffinityConfig("claude", fold, "claude-entry1", { enabled: true, homeDir: home, braneDir });
|
|
249
|
+
assert.deepEqual(launch, { args: ["--settings", claudeSessionAffinityPath("claude-entry1", { braneDir })], env: "" });
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
it("does nothing when opted out", () => {
|
|
253
|
+
const { fold, home, braneDir } = fireworksFold();
|
|
254
|
+
withCleanEnv(() => {
|
|
255
|
+
const launch = ensureSessionAffinityConfig("claude", fold, "claude-off1", { enabled: false, homeDir: home, braneDir });
|
|
256
|
+
assert.deepEqual(launch, { args: [], env: "" });
|
|
257
|
+
});
|
|
258
|
+
assert.equal(existsSync(join(braneDir, "session-affinity")), false);
|
|
259
|
+
});
|
|
260
|
+
it("does nothing for other agents — crush sends its own affinity header", () => {
|
|
261
|
+
const { fold, home, braneDir } = fireworksFold();
|
|
262
|
+
withCleanEnv(() => {
|
|
263
|
+
for (const agent of ["crush", "goose", "codex", "gemini"]) {
|
|
264
|
+
assert.deepEqual(ensureSessionAffinityConfig(agent, fold, "x-other1", { enabled: true, homeDir: home, braneDir }), {
|
|
265
|
+
args: [],
|
|
266
|
+
env: "",
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
it("degrades to no affinity, not a failed launch, when the file cannot be written", () => {
|
|
272
|
+
const { fold, home } = fireworksFold();
|
|
273
|
+
// A regular file where the brane dir should be makes the mkdir throw.
|
|
274
|
+
const blocked = join(tempDir("brane-affinity-blocked-"), "not-a-dir");
|
|
275
|
+
writeFileSync(blocked, "");
|
|
276
|
+
const logged = [];
|
|
277
|
+
const original = console.error;
|
|
278
|
+
console.error = (...args) => logged.push(args);
|
|
279
|
+
try {
|
|
280
|
+
withCleanEnv(() => {
|
|
281
|
+
const launch = ensureSessionAffinityConfig("claude", fold, "claude-err1", {
|
|
282
|
+
enabled: true,
|
|
283
|
+
homeDir: home,
|
|
284
|
+
braneDir: blocked,
|
|
285
|
+
});
|
|
286
|
+
assert.deepEqual(launch, { args: [], env: "" });
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
finally {
|
|
290
|
+
console.error = original;
|
|
291
|
+
}
|
|
292
|
+
assert.equal(logged.length, 1);
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
//# sourceMappingURL=session-affinity.test.js.map
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who a human_signal is from (src/daemon/signal-source.ts): decided by the
|
|
3
|
+
* token's kind, never by a `source` the request names, and refused to an
|
|
4
|
+
* agent's token.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, it } from "node:test";
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { AGENT_SIGNAL_REFUSAL, signalEventFields, signalStanding } from "../daemon/signal-source.js";
|
|
9
|
+
describe("signalStanding", () => {
|
|
10
|
+
it("stamps a device's signal as the app's", () => {
|
|
11
|
+
assert.deepEqual(signalStanding({ kind: "device" }), { source: "web" });
|
|
12
|
+
});
|
|
13
|
+
it("stamps this account's signal as local", () => {
|
|
14
|
+
assert.deepEqual(signalStanding({ kind: "local" }), { source: "local" });
|
|
15
|
+
});
|
|
16
|
+
it("refuses an agent's token, pointing it at narrate", () => {
|
|
17
|
+
const standing = signalStanding({ kind: "agent" });
|
|
18
|
+
assert.deepEqual(standing, { refused: AGENT_SIGNAL_REFUSAL });
|
|
19
|
+
assert.match(AGENT_SIGNAL_REFUSAL, /narrate/);
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
describe("signalEventFields", () => {
|
|
23
|
+
it("ignores a source the request names", () => {
|
|
24
|
+
const fields = signalEventFields({ source: "web" }, { content: "hi", source: "claude-abcd1234", re: [3] });
|
|
25
|
+
assert.deepEqual(fields, { content: "hi", source: "web", re: [3] });
|
|
26
|
+
});
|
|
27
|
+
it("carries stored payload paths, and nothing else from the request", () => {
|
|
28
|
+
const fields = signalEventFields({ source: "local" }, { content: "see attached", type: "human_signal", meta: { forged: true } }, ["claude-abcd1234/1_0.png"]);
|
|
29
|
+
assert.deepEqual(fields, { content: "see attached", source: "local", re: undefined, payloads: ["claude-abcd1234/1_0.png"] });
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
//# sourceMappingURL=signal-source.test.js.map
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A request over `daemon.sock` with no token acts as this Unix account — the
|
|
3
|
+
* live `local` row — while the brane dir is owned by the daemon's uid with no
|
|
4
|
+
* group or other bits. No token anywhere else is still refused, and clients
|
|
5
|
+
* go tokenless only when they have nothing to present and a socket to use.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, it, after } from "node:test";
|
|
8
|
+
import assert from "node:assert/strict";
|
|
9
|
+
import { createServer as createHttpServer } from "node:http";
|
|
10
|
+
import { chmodSync, mkdtempSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { createServer as createNetServer, connect as netConnect } from "node:net";
|
|
14
|
+
import { createTestDb } from "./helpers.js";
|
|
15
|
+
import { accountDirTrust, markUnixConnection } from "../daemon/local-socket.js";
|
|
16
|
+
import { authenticateRequest } from "../daemon/server.js";
|
|
17
|
+
import { MemoryTokenStore, TokenRing, hashToken } from "../daemon/token-ring.js";
|
|
18
|
+
import { bootstrapTokens, DbTokenStore } from "../db/tokens.js";
|
|
19
|
+
import { daemonRequest, resolveCredential } from "../daemon/client.js";
|
|
20
|
+
import { mcpDialTarget } from "../commands/mcp.js";
|
|
21
|
+
import { leftoverTokenFile, shellReachProblem, tokenFileCheck } from "../commands/doctor.js";
|
|
22
|
+
const unixOnly = process.getuid === undefined ? "no Unix uids on this platform" : false;
|
|
23
|
+
function privateDir() {
|
|
24
|
+
const dir = mkdtempSync(join(tmpdir(), "brane-acct-"));
|
|
25
|
+
chmodSync(dir, 0o700);
|
|
26
|
+
return dir;
|
|
27
|
+
}
|
|
28
|
+
/** A request as the CLI and bridge send one: `Host: localhost`, no Origin. */
|
|
29
|
+
function request(opts) {
|
|
30
|
+
const socket = opts.overSocket ? {} : { remoteAddress: opts.remoteAddress ?? "127.0.0.1" };
|
|
31
|
+
if (opts.overSocket)
|
|
32
|
+
markUnixConnection(socket);
|
|
33
|
+
return { headers: { host: "localhost", ...opts.headers }, socket };
|
|
34
|
+
}
|
|
35
|
+
describe("accountDirTrust", { skip: unixOnly }, () => {
|
|
36
|
+
it("trusts a directory this uid owns with no group or other bits", () => {
|
|
37
|
+
assert.deepEqual(accountDirTrust(privateDir()), { ok: true });
|
|
38
|
+
});
|
|
39
|
+
it("refuses one other accounts can search, and says so", () => {
|
|
40
|
+
const dir = privateDir();
|
|
41
|
+
chmodSync(dir, 0o755);
|
|
42
|
+
const trust = accountDirTrust(dir);
|
|
43
|
+
assert.equal(trust.ok, false);
|
|
44
|
+
assert.match(trust.reason, /mode 755/);
|
|
45
|
+
});
|
|
46
|
+
it("refuses a directory that is not there", () => {
|
|
47
|
+
assert.equal(accountDirTrust(join(privateDir(), "gone")).ok, false);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
describe("authenticateRequest", { skip: unixOnly }, () => {
|
|
51
|
+
const ring = () => new TokenRing(new MemoryTokenStore([{ token: "local-tok", kind: "local", label: "this machine" }]), () => "minted");
|
|
52
|
+
it("acts as the local row for a tokenless request over the socket", () => {
|
|
53
|
+
const heard = [];
|
|
54
|
+
const result = authenticateRequest(ring(), null, request({ overSocket: true }), privateDir(), (r) => heard.push(r));
|
|
55
|
+
assert.equal(result.ok, true);
|
|
56
|
+
assert.equal(result.ok && result.record.kind, "local");
|
|
57
|
+
// Told it was let in, so a later refusal is reported again.
|
|
58
|
+
assert.deepEqual(heard, [null]);
|
|
59
|
+
});
|
|
60
|
+
it("treats an empty token as no token", () => {
|
|
61
|
+
assert.equal(authenticateRequest(ring(), "", request({ overSocket: true }), privateDir(), () => { }).ok, true);
|
|
62
|
+
});
|
|
63
|
+
it("refuses a browser's request relayed to the socket: an Origin, or a Host other than localhost", () => {
|
|
64
|
+
const reasons = [];
|
|
65
|
+
const relayed = [
|
|
66
|
+
request({ overSocket: true, headers: { origin: "https://evil.example" } }),
|
|
67
|
+
request({ overSocket: true, headers: { host: "evil.example:9000" } }),
|
|
68
|
+
request({ overSocket: true, headers: { host: "" } }),
|
|
69
|
+
];
|
|
70
|
+
for (const req of relayed) {
|
|
71
|
+
assert.deepEqual(authenticateRequest(ring(), null, req, privateDir(), (r) => reasons.push(r)), { ok: false });
|
|
72
|
+
}
|
|
73
|
+
assert.equal(reasons.filter((r) => r !== null).length, 3);
|
|
74
|
+
// A presented token is still just checked: a relay with a token was
|
|
75
|
+
// always as good as the token.
|
|
76
|
+
const withToken = request({ overSocket: true, headers: { origin: "https://evil.example" } });
|
|
77
|
+
assert.equal(authenticateRequest(ring(), "local-tok", withToken, privateDir(), () => { }).ok, true);
|
|
78
|
+
});
|
|
79
|
+
it("refuses the same request over TCP, loopback included", () => {
|
|
80
|
+
const result = authenticateRequest(ring(), null, request({ overSocket: false, remoteAddress: "127.0.0.1" }), privateDir(), () => { });
|
|
81
|
+
assert.deepEqual(result, { ok: false });
|
|
82
|
+
});
|
|
83
|
+
it("refuses it, and says why, when the brane dir is open to others", () => {
|
|
84
|
+
const dir = privateDir();
|
|
85
|
+
chmodSync(dir, 0o750);
|
|
86
|
+
const reasons = [];
|
|
87
|
+
const result = authenticateRequest(ring(), null, request({ overSocket: true }), dir, (r) => reasons.push(r));
|
|
88
|
+
assert.deepEqual(result, { ok: false });
|
|
89
|
+
assert.equal(reasons.length, 1);
|
|
90
|
+
assert.match(reasons[0] ?? "", /mode 750/);
|
|
91
|
+
});
|
|
92
|
+
it("still checks a presented token, over the socket too", () => {
|
|
93
|
+
const r = ring();
|
|
94
|
+
assert.equal(authenticateRequest(r, "nope", request({ overSocket: true }), privateDir(), () => { }).ok, false);
|
|
95
|
+
assert.equal(authenticateRequest(r, "local-tok", request({ overSocket: true }), privateDir(), () => { }).ok, true);
|
|
96
|
+
});
|
|
97
|
+
it("refuses when there is no live local row to act as", () => {
|
|
98
|
+
const empty = new TokenRing(new MemoryTokenStore(), () => "minted");
|
|
99
|
+
assert.deepEqual(authenticateRequest(empty, null, request({ overSocket: true }), privateDir(), () => { }), { ok: false });
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
describe("DbTokenStore.account", () => {
|
|
103
|
+
it("is the live local row, stamped with where it was seen, and nothing once revoked", () => {
|
|
104
|
+
const db = createTestDb();
|
|
105
|
+
const store = new DbTokenStore(db);
|
|
106
|
+
// Last-used is stamped at most once a minute, so use a clock past that.
|
|
107
|
+
const t = 10 * 60_000;
|
|
108
|
+
const local = store.insert(hashToken("x"), "local", "this machine", 1000);
|
|
109
|
+
const found = store.account(t, { userAgent: "node", remoteAddr: "daemon.sock" });
|
|
110
|
+
assert.equal(found?.id, local.id);
|
|
111
|
+
const row = db.prepare("SELECT remote_addr, last_used_at FROM tokens WHERE id = ?").get(local.id);
|
|
112
|
+
assert.equal(row.remote_addr, "daemon.sock");
|
|
113
|
+
assert.equal(row.last_used_at, t);
|
|
114
|
+
store.revoke([local.id], t + 1);
|
|
115
|
+
assert.equal(store.account(t + 2), null);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
describe("clients without a token", () => {
|
|
119
|
+
const closers = [];
|
|
120
|
+
after(async () => {
|
|
121
|
+
for (const close of closers)
|
|
122
|
+
await close();
|
|
123
|
+
});
|
|
124
|
+
it("present nothing outside an agent session, and that session's token inside one", () => {
|
|
125
|
+
assert.equal(resolveCredential({}), null);
|
|
126
|
+
assert.equal(resolveCredential({ BRANE_NARRATIVE_SECRET: "" }), null);
|
|
127
|
+
assert.equal(resolveCredential({ BRANE_NARRATIVE_SECRET: "agent-secret" }), "agent-secret");
|
|
128
|
+
});
|
|
129
|
+
it("say what is wrong when there is no socket to present nothing on", async () => {
|
|
130
|
+
const noSocket = { baseUrl: "http://127.0.0.1:1", token: null, port: "1" };
|
|
131
|
+
await assert.rejects(daemonRequest(noSocket, "/api/status"), /running without its socket.*brane daemon logs/);
|
|
132
|
+
const gone = { ...noSocket, socketPath: join(privateDir(), "daemon.sock") };
|
|
133
|
+
await assert.rejects(daemonRequest(gone, "/api/status"), /could not connect to the daemon's socket.*brane daemon restart/);
|
|
134
|
+
});
|
|
135
|
+
it("send no Authorization over the socket", async () => {
|
|
136
|
+
const socketPath = join(privateDir(), "daemon.sock");
|
|
137
|
+
const seen = [];
|
|
138
|
+
const server = createHttpServer((req, res) => {
|
|
139
|
+
seen.push(req.headers.authorization);
|
|
140
|
+
res.end("{}");
|
|
141
|
+
});
|
|
142
|
+
await new Promise((r) => server.listen(socketPath, r));
|
|
143
|
+
closers.push(() => new Promise((r) => server.close(() => r())));
|
|
144
|
+
await daemonRequest({ baseUrl: "http://127.0.0.1:1", token: null, port: "1", socketPath }, "/api/status");
|
|
145
|
+
assert.deepEqual(seen, [undefined]);
|
|
146
|
+
});
|
|
147
|
+
it("never fall back to TCP, where no token is only a 401", async () => {
|
|
148
|
+
let tcpHits = 0;
|
|
149
|
+
const tcp = createHttpServer((_req, res) => {
|
|
150
|
+
tcpHits++;
|
|
151
|
+
res.end("{}");
|
|
152
|
+
});
|
|
153
|
+
await new Promise((r) => tcp.listen(0, "127.0.0.1", r));
|
|
154
|
+
closers.push(() => new Promise((r) => tcp.close(() => r())));
|
|
155
|
+
const port = tcp.address().port;
|
|
156
|
+
const endpoint = {
|
|
157
|
+
baseUrl: `http://127.0.0.1:${port}`,
|
|
158
|
+
token: null,
|
|
159
|
+
port: String(port),
|
|
160
|
+
socketPath: join(privateDir(), "daemon.sock"),
|
|
161
|
+
};
|
|
162
|
+
await assert.rejects(daemonRequest(endpoint, "/api/status"), /could not connect to the daemon's socket/);
|
|
163
|
+
assert.equal(tcpHits, 0);
|
|
164
|
+
});
|
|
165
|
+
it("dial the bridge with no token in the URL", () => {
|
|
166
|
+
assert.deepEqual(mcpDialTarget({ port: "4000", tls: true, token: null, socketPath: "/s" }), {
|
|
167
|
+
url: "ws://localhost/ws/mcp?",
|
|
168
|
+
socketPath: "/s",
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
describe("tokenless over a real socket", { skip: unixOnly }, () => {
|
|
173
|
+
it("is let in while the dir is private, refused with a hint when it is not, and refused through a relay", async () => {
|
|
174
|
+
const dir = privateDir();
|
|
175
|
+
const socketPath = join(dir, "daemon.sock");
|
|
176
|
+
const ring = new TokenRing(new MemoryTokenStore([{ token: "local-tok", kind: "local" }]), () => "minted");
|
|
177
|
+
// The daemon's shape: mark socket connections, decide, answer.
|
|
178
|
+
const server = createHttpServer((req, res) => {
|
|
179
|
+
const auth = authenticateRequest(ring, null, req, dir, () => { });
|
|
180
|
+
res.writeHead(auth.ok ? 200 : 401, { "Content-Type": "application/json" });
|
|
181
|
+
res.end(JSON.stringify(auth.ok ? { kind: auth.record.kind } : { error: "Unauthorized" }));
|
|
182
|
+
});
|
|
183
|
+
server.on("connection", (s) => markUnixConnection(s));
|
|
184
|
+
await new Promise((r) => server.listen(socketPath, r));
|
|
185
|
+
// A plain TCP relay in front of the socket, as `ssh -L` or socat would be.
|
|
186
|
+
const piped = new Set();
|
|
187
|
+
const relay = createNetServer((c) => {
|
|
188
|
+
const upstream = netConnect(socketPath);
|
|
189
|
+
piped.add(c).add(upstream);
|
|
190
|
+
c.pipe(upstream).pipe(c);
|
|
191
|
+
});
|
|
192
|
+
await new Promise((r) => relay.listen(0, "127.0.0.1", r));
|
|
193
|
+
const relayPort = relay.address().port;
|
|
194
|
+
try {
|
|
195
|
+
const endpoint = { baseUrl: "http://127.0.0.1:1", token: null, port: "1", socketPath };
|
|
196
|
+
assert.deepEqual(await daemonRequest(endpoint, "/api/status"), { kind: "local" });
|
|
197
|
+
chmodSync(dir, 0o755);
|
|
198
|
+
await assert.rejects(daemonRequest(endpoint, "/api/status"), /chmod 700 ~\/\.brane/);
|
|
199
|
+
chmodSync(dir, 0o700);
|
|
200
|
+
assert.deepEqual(await daemonRequest(endpoint, "/api/status"), { kind: "local" });
|
|
201
|
+
// What a web page can send with no preflight, through the relay.
|
|
202
|
+
const res = await fetch(`http://127.0.0.1:${relayPort}/api/spawn`, {
|
|
203
|
+
method: "POST",
|
|
204
|
+
headers: { Origin: "https://evil.example", "Content-Type": "text/plain" },
|
|
205
|
+
body: "{}",
|
|
206
|
+
});
|
|
207
|
+
assert.equal(res.status, 401);
|
|
208
|
+
}
|
|
209
|
+
finally {
|
|
210
|
+
chmodSync(dir, 0o700);
|
|
211
|
+
// fetch keeps its connection alive; don't wait for it to idle out.
|
|
212
|
+
for (const sock of piped)
|
|
213
|
+
sock.destroy();
|
|
214
|
+
await new Promise((r) => relay.close(() => r()));
|
|
215
|
+
server.closeAllConnections();
|
|
216
|
+
await new Promise((r) => server.close(() => r()));
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
describe("doctor's reach check", { skip: unixOnly }, () => {
|
|
221
|
+
it("passes with a secret; without one it needs a socket and a private dir, and a token file is no way in", () => {
|
|
222
|
+
const dir = privateDir();
|
|
223
|
+
const base = { secret: undefined, socketPath: undefined, braneDir: dir };
|
|
224
|
+
assert.equal(shellReachProblem({ ...base, secret: "agent" }), null);
|
|
225
|
+
assert.match(shellReachProblem(base) ?? "", /not listening on its socket/);
|
|
226
|
+
writeFileSync(join(dir, ".daemon_token"), "tok\n");
|
|
227
|
+
assert.match(shellReachProblem(base) ?? "", /not listening on its socket/);
|
|
228
|
+
assert.equal(shellReachProblem({ ...base, socketPath: join(dir, "daemon.sock") }), null);
|
|
229
|
+
chmodSync(dir, 0o755);
|
|
230
|
+
assert.match(shellReachProblem({ ...base, socketPath: join(dir, "daemon.sock") }) ?? "", /chmod 700/);
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
describe("doctor's token file check", () => {
|
|
234
|
+
it("says nothing without a file, or before this version's daemon has started once", () => {
|
|
235
|
+
const db = createTestDb();
|
|
236
|
+
const path = join(privateDir(), ".daemon_token");
|
|
237
|
+
assert.equal(tokenFileCheck(db, path), null);
|
|
238
|
+
// An older brane's file, its token still the live local row: the next
|
|
239
|
+
// daemon start retires it, and until then there is nothing to report.
|
|
240
|
+
new DbTokenStore(db).insert(hashToken("old-local"), "local", "this machine", 1);
|
|
241
|
+
writeFileSync(path, "old-local\n");
|
|
242
|
+
assert.equal(tokenFileCheck(db, path), null);
|
|
243
|
+
});
|
|
244
|
+
it("reports the emptied leftover, and a file the daemon could not empty by how its token stands", () => {
|
|
245
|
+
const db = createTestDb();
|
|
246
|
+
const path = join(privateDir(), ".daemon_token");
|
|
247
|
+
const store = new DbTokenStore(db);
|
|
248
|
+
store.insert(hashToken("old-local"), "local", "this machine", 1);
|
|
249
|
+
store.insert(hashToken("legacy"), "device", "melded before hashing", 1, { used: true });
|
|
250
|
+
writeFileSync(path, "old-local\n");
|
|
251
|
+
bootstrapTokens(db, { tokenFile: path, now: 2 });
|
|
252
|
+
assert.match(tokenFileCheck(db, path)?.detail ?? "", /empty leftover/);
|
|
253
|
+
writeFileSync(path, "old-local\n");
|
|
254
|
+
assert.match(tokenFileCheck(db, path)?.detail ?? "", /could not empty.*no longer works/);
|
|
255
|
+
writeFileSync(path, "legacy\n");
|
|
256
|
+
assert.match(tokenFileCheck(db, path)?.detail ?? "", /still holds a token that works/);
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
describe("doctor on a leftover .daemon_token", () => {
|
|
260
|
+
it("says the empty file can go, and warns when one still holds a token", () => {
|
|
261
|
+
const empty = leftoverTokenFile("/b/.daemon_token", "empty");
|
|
262
|
+
assert.equal(empty.status, "warn");
|
|
263
|
+
assert.match(empty.detail, /empty leftover.*rm \/b\/\.daemon_token/);
|
|
264
|
+
assert.match(leftoverTokenFile("/b/.daemon_token", "dead").detail, /could not empty.*no longer works.*rm /);
|
|
265
|
+
assert.match(leftoverTokenFile("/b/.daemon_token", "live").detail, /still holds a token that works.*rm /);
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
//# sourceMappingURL=socket-account.test.js.map
|