driggsby 0.5.2 → 0.6.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/README.md CHANGED
@@ -22,6 +22,12 @@ npx driggsby@latest mcp setup other
22
22
  After setup, authenticate Driggsby through your client — run `/mcp` in
23
23
  Claude Code, or sign in through the browser window Codex opens.
24
24
 
25
+ So your Driggsby MCP page can tell your connections apart, `login` sends
26
+ Driggsby this computer's name and operating system version (for example
27
+ `mbp-studio` and `macOS 15.6`) with its sign-in, and
28
+ `mcp setup claude-code` saves them as headers on the Driggsby entry it
29
+ adds to Claude Code, which sends them with each request.
30
+
25
31
  For another MCP client, choose Other. Driggsby currently supports only
26
32
  OAuth-based remote MCP at:
27
33
 
package/dist/device.js ADDED
@@ -0,0 +1,127 @@
1
+ import { readFileSync, statSync } from "node:fs";
2
+ import { isIP } from "node:net";
3
+ import { hostname, platform, release, version } from "node:os";
4
+ // Load-bearing: these allowlists are what make the values safe as command
5
+ // arguments (spawn-plan.ts quotes them for cmd.exe) and as "Name: value"
6
+ // headers. They must never admit % ! ^ & | < > " \ : or a control
7
+ // character; device.test.ts checks each.
8
+ const NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,62}$/;
9
+ const SYSTEM_PATTERN = /^[A-Za-z0-9][A-Za-z0-9 ._/-]{0,63}$/;
10
+ const MAC_VERSION_FILE = "/System/Library/CoreServices/SystemVersion.plist";
11
+ // os-release(5): /etc first, then the vendor copy.
12
+ const LINUX_RELEASE_FILES = ["/etc/os-release", "/usr/lib/os-release"];
13
+ // Both files are a few hundred bytes; anything larger isn't one.
14
+ const MAX_FILE_BYTES = 64 * 1024;
15
+ // A PRETTY_NAME is a short line; a longer one is not parsed at all.
16
+ const MAX_PRETTY_NAME_CHARS = 256;
17
+ // Hostname labels that spell out an address ("ip-172-31-22-33",
18
+ // "c-73-162-12-34", "192-168-1-20") or are only digits name no computer.
19
+ const ADDRESS_LABEL = /^((ip|c)-)?\d{1,3}(-\d{1,3}){3}$|^\d+$/i;
20
+ // Windows 11 reports itself as NT 10.0; its builds start at 22000.
21
+ const WINDOWS_11_FIRST_BUILD = 22000;
22
+ export function describeDevice(facts) {
23
+ try {
24
+ const known = facts ?? realOsFacts();
25
+ return { name: deviceName(known.hostname), system: deviceSystem(known) };
26
+ }
27
+ catch {
28
+ // os.hostname() and os.release() can throw in unusual runtimes.
29
+ return { name: null, system: null };
30
+ }
31
+ }
32
+ // The known values alone, as the sign-in request carries them; null when
33
+ // nothing is known.
34
+ export function knownDevice(device) {
35
+ const known = {
36
+ ...(device.name === null ? {} : { name: device.name }),
37
+ ...(device.system === null ? {} : { system: device.system }),
38
+ };
39
+ return Object.keys(known).length === 0 ? null : known;
40
+ }
41
+ // The Driggsby headers `mcp setup` gives Claude Code, one per known value.
42
+ export function deviceHeaders(device) {
43
+ const headers = [];
44
+ if (device.name !== null) {
45
+ headers.push(`X-Driggsby-Device-Name: ${device.name}`);
46
+ }
47
+ if (device.system !== null) {
48
+ headers.push(`X-Driggsby-Device-System: ${device.system}`);
49
+ }
50
+ return headers;
51
+ }
52
+ // "mbp-studio.local" and "devbox-02.corp.example.test" are mbp-studio and
53
+ // devbox-02: the first label is the name people give their computer. An
54
+ // address or localhost names no computer.
55
+ function deviceName(raw) {
56
+ const trimmed = raw.trim();
57
+ if (isIP(trimmed) !== 0) {
58
+ return null;
59
+ }
60
+ const first = trimmed.split(".")[0] ?? "";
61
+ if (first.toLowerCase() === "localhost" || ADDRESS_LABEL.test(first)) {
62
+ return null;
63
+ }
64
+ return NAME_PATTERN.test(first) ? first : null;
65
+ }
66
+ function deviceSystem(facts) {
67
+ switch (facts.platform) {
68
+ case "darwin":
69
+ return allowed(macVersion(facts)) ?? "macOS";
70
+ case "linux":
71
+ return allowed(linuxPrettyName(facts)) ?? "Linux";
72
+ case "win32":
73
+ return facts.version.includes("Server") ? "Windows Server" : windowsName(facts.release);
74
+ default:
75
+ return null;
76
+ }
77
+ }
78
+ function macVersion(facts) {
79
+ const plist = facts.readFile(MAC_VERSION_FILE);
80
+ const version = plist?.match(/<key>ProductVersion<\/key>\s*<string>([0-9.]+)<\/string>/)?.[1];
81
+ return version === undefined ? null : `macOS ${version}`;
82
+ }
83
+ // PRETTY_NAME="Debian GNU/Linux 12 (bookworm)" is Debian GNU/Linux 12.
84
+ function linuxPrettyName(facts) {
85
+ for (const path of LINUX_RELEASE_FILES) {
86
+ const osRelease = facts.readFile(path);
87
+ if (osRelease === null) {
88
+ continue;
89
+ }
90
+ const line = osRelease.split(/\r?\n/).find((each) => each.startsWith("PRETTY_NAME="));
91
+ if (line === undefined || line.length > MAX_PRETTY_NAME_CHARS) {
92
+ return null;
93
+ }
94
+ const value = line.slice("PRETTY_NAME=".length).trim().replace(/^["']|["']$/g, "");
95
+ return value.replace(/\s*\([^)]*\)\s*$/, "").trim();
96
+ }
97
+ return null;
98
+ }
99
+ // Windows 10 and 11 both report NT 10.0; anything else is just Windows.
100
+ function windowsName(nodeRelease) {
101
+ const [major, , buildText] = nodeRelease.split(".");
102
+ const build = Number(buildText);
103
+ if (major !== "10" || !Number.isInteger(build)) {
104
+ return "Windows";
105
+ }
106
+ return build >= WINDOWS_11_FIRST_BUILD ? "Windows 11" : "Windows 10";
107
+ }
108
+ function allowed(value) {
109
+ return value !== null && SYSTEM_PATTERN.test(value) ? value : null;
110
+ }
111
+ function realOsFacts() {
112
+ return { hostname: hostname(), platform: platform(), release: release(), version: version(), readFile: readTextFile };
113
+ }
114
+ // A regular file of a sane size only: never a FIFO or device node that
115
+ // would block, never something huge.
116
+ function readTextFile(path) {
117
+ try {
118
+ const stat = statSync(path);
119
+ if (!stat.isFile() || stat.size > MAX_FILE_BYTES) {
120
+ return null;
121
+ }
122
+ return readFileSync(path, "utf8");
123
+ }
124
+ catch {
125
+ return null;
126
+ }
127
+ }
@@ -4,6 +4,7 @@
4
4
  // token is returned by exactly one poll; callers must never poll again after
5
5
  // an approval.
6
6
  import { CliError } from "../cli-error.js";
7
+ import { knownDevice } from "../device.js";
7
8
  import { sanitizeForTerminal } from "../terminal-text.js";
8
9
  // The CLI's own sign-in: read, deploy, and the transaction-rule tools.
9
10
  const CLAIM_SCOPE = "driggsby.cli";
@@ -23,11 +24,13 @@ export const SIGN_IN_START_FAILURE = "We weren't able to start a Driggsby sign-i
23
24
  // Poll failures happen after the user may already have approved, so "start a
24
25
  // sign-in" would be the wrong stage to describe.
25
26
  const SIGN_IN_FINISH_FAILURE = "We weren't able to finish your Driggsby sign-in just now. Please try again\nin a minute.";
26
- export async function createClaimRequest(baseUrl) {
27
+ // device: this computer's name and system (device.ts), so the sign-in shows
28
+ // up on Driggsby's MCP page as the computer it is; only known values go.
29
+ export async function createClaimRequest(baseUrl, device = { name: null, system: null }) {
27
30
  const response = await fetch(`${baseUrl}/app-tokens/claim-requests`, {
28
31
  method: "POST",
29
32
  headers: { "Content-Type": "application/json" },
30
- body: JSON.stringify({ app_name: CLAIM_APP_NAME, scope: CLAIM_SCOPE }),
33
+ body: JSON.stringify({ app_name: CLAIM_APP_NAME, scope: CLAIM_SCOPE, ...deviceField(device) }),
31
34
  signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
32
35
  // Never follow a redirect: a 307/308 would replay this claim flow —
33
36
  // and, on the poll below, the poll secret — to whatever origin a
@@ -141,6 +144,11 @@ function stringField(record, field) {
141
144
  const value = record?.[field];
142
145
  return typeof value === "string" && value !== "" ? value : null;
143
146
  }
147
+ // The claim body's device field: only the values this computer knows.
148
+ function deviceField(device) {
149
+ const known = knownDevice(device);
150
+ return known === null ? {} : { device: known };
151
+ }
144
152
  // The server's user-facing error text, stripped of terminal control bytes
145
153
  // like every other string this CLI did not author, and length-bounded.
146
154
  function errorDescription(body) {
@@ -2,9 +2,10 @@
2
2
  // page, poll until they approve, and store the token. The token is shown to
3
3
  // no one — it goes straight from the poll response into the credential
4
4
  // store.
5
- import { apiBaseUrl, apiHost } from "../api/base-url.js";
5
+ import { apiBaseUrl, apiHost, baseUrlMayReceiveSavedSignIn } from "../api/base-url.js";
6
6
  import { blockedNetworkError } from "../api/network-error.js";
7
7
  import { CliError } from "../cli-error.js";
8
+ import { describeDevice } from "../device.js";
8
9
  import { defaultCredentialEnvironment, describeStorageLocation, environmentToken, saveToken, } from "../credentials/store.js";
9
10
  import { sanitizeForTerminal, wrapProse } from "../terminal-text.js";
10
11
  import { createClaimRequest, pollClaimRequest, SIGN_IN_START_FAILURE, } from "./claim-client.js";
@@ -69,7 +70,10 @@ async function storeApprovedToken(appToken, environment, warn) {
69
70
  }
70
71
  async function createClaim(baseUrl) {
71
72
  try {
72
- return await createClaimRequest(baseUrl);
73
+ // This computer's name goes only to Driggsby (or a local run), never
74
+ // to another host a base-URL override names.
75
+ const device = baseUrlMayReceiveSavedSignIn(baseUrl) ? describeDevice() : { name: null, system: null };
76
+ return await createClaimRequest(baseUrl, device);
73
77
  }
74
78
  catch (error) {
75
79
  const blocked = blockedNetworkError(error, {
@@ -1,6 +1,11 @@
1
1
  import {} from "./known-client.js";
2
2
  export const DRIGGSBY_MCP_URL = "https://app.driggsby.com/mcp";
3
- export function buildInstallerCommand(client, scope) {
3
+ // headers: this computer's name and system for Driggsby's MCP page
4
+ // (device.ts builds them from allowlisted values only). Claude Code takes
5
+ // them after the URL (--header is variadic, so before the name it would
6
+ // swallow it); Codex has no header option, so it never gets any. The
7
+ // commands printed for a person to run carry none.
8
+ export function buildInstallerCommand(client, scope, headers = []) {
4
9
  switch (client) {
5
10
  case "claude-code":
6
11
  return {
@@ -14,6 +19,7 @@ export function buildInstallerCommand(client, scope) {
14
19
  scope ?? "user",
15
20
  "driggsby",
16
21
  DRIGGSBY_MCP_URL,
22
+ ...headers.flatMap((header) => ["--header", header]),
17
23
  ],
18
24
  };
19
25
  case "codex":
@@ -1,5 +1,6 @@
1
1
  import { createInterface } from "node:readline/promises";
2
2
  import { CliError } from "../cli-error.js";
3
+ import { describeDevice, deviceHeaders } from "../device.js";
3
4
  import { trimLikeRust } from "../terminal-text.js";
4
5
  import { runClientCommand } from "./client-command.js";
5
6
  import { classifyExistingMcpConfig } from "./classify.js";
@@ -24,7 +25,11 @@ export async function runMcpSetup(options) {
24
25
  return;
25
26
  }
26
27
  write(`Adding Driggsby to ${displayName(client)} MCP config...\n`);
27
- const result = await runClientCommand(installer, streamConfigOutput(client));
28
+ // The command it runs also names this computer (Claude Code only); the
29
+ // commands it prints for a person stay the plain, portable ones.
30
+ const headers = installClient === "claude-code" ? deviceHeaders(describeDevice()) : [];
31
+ const runInstaller = buildInstallerCommand(installClient, options.scope, headers);
32
+ const result = await runClientCommand(runInstaller, streamConfigOutput(client));
28
33
  switch (result.kind) {
29
34
  case "output":
30
35
  if (result.output.succeeded) {
@@ -5,8 +5,9 @@
5
5
  // PATH + PATHEXT (never the current directory, which Windows shells would
6
6
  // search first) and run shims through an explicit, absolute cmd.exe
7
7
  // invocation with our own quoting. Real commands only ever carry fixed
8
- // constant arguments (see commands.ts); nothing user-controlled may ever
9
- // flow into a spawn plan.
8
+ // constant arguments (see commands.ts) or this computer's name and system,
9
+ // allowlisted by device.ts to characters cmd.exe treats as plain; nothing
10
+ // user-controlled may ever flow into a spawn plan.
10
11
  import { existsSync } from "node:fs";
11
12
  import { isAbsolute, join } from "node:path";
12
13
  import {} from "./commands.js";
@@ -80,8 +81,9 @@ export function resolveWindowsProgram(program, lookup = defaultLookup()) {
80
81
  // (the cmd convention) and trailing backslashes doubled (so a path ending
81
82
  // in \ cannot escape the closing quote for the child's own parser). The
82
83
  // safe set covers every constant argument the CLI actually runs, including
83
- // the MCP URL; only fixed constants and PATH-resolved program paths may
84
- // reach this function.
84
+ // the MCP URL; only fixed constants, PATH-resolved program paths, and the
85
+ // device values device.ts allowlists (no % ! ^ & | < > " \ or control
86
+ // characters) may reach this function.
85
87
  export function quoteForCmd(value) {
86
88
  if (/^[A-Za-z0-9_\-.:\\/=]+$/.test(value)) {
87
89
  return value;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "driggsby",
3
- "version": "0.5.2",
3
+ "version": "0.6.0",
4
4
  "description": "Set up and build on Driggsby, the MCP server for personal finance.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -28,7 +28,7 @@
28
28
  "node": ">=18"
29
29
  },
30
30
  "dependencies": {
31
- "@driggsby/deploy": "0.5.2",
32
- "@driggsby/sdk": "0.5.2"
31
+ "@driggsby/deploy": "0.6.0",
32
+ "@driggsby/sdk": "0.6.0"
33
33
  }
34
34
  }