@commonlyai/cli 0.1.61 → 0.1.64

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.
@@ -0,0 +1,110 @@
1
+ /**
2
+ * How a declared MCP server receives the seat credential, for the two adapters
3
+ * that cannot hand it over on a pipe.
4
+ *
5
+ * The pi bridge spawns the server itself, so it can pipe the credential on an
6
+ * inherited fd (see `pi-mcp-client.mjs`). Claude and codex do not: each of them
7
+ * starts the server inside its own process tree, so the only two channels
8
+ * available are (a) a value in the runtime's environment, which the whole
9
+ * subtree inherits, or (b) a PATH in the declaration that the server itself
10
+ * reads. (b) is the one that leaves the token where it belongs.
11
+ *
12
+ * This module makes that choice in ONE place, because three call sites deciding
13
+ * version thresholds independently is how one of them ends up handing over the
14
+ * value while the others hand over the path.
15
+ *
16
+ * The rewrite is of the DECLARATION, not of the value: an entry that named
17
+ * `COMMONLY_AGENT_TOKEN` comes back naming `COMMONLY_TOKEN_FILE`, so the value
18
+ * never exists in the runtime's environment to be inherited, logged, or dumped
19
+ * by an unrelated MCP server that a seat was granted.
20
+ */
21
+ import { CREDENTIAL_FILE_VAR, CREDENTIAL_KEY } from './credential-file.js';
22
+ import {
23
+ FILE_READER_VERSION, MCP_PACKAGE, describeMcpCommand, versionOlderThan,
24
+ } from './mcp-server-version.js';
25
+
26
+ /** What a declaration should say to receive the credential as a path. */
27
+ export const CREDENTIAL_FILE_PLACEHOLDER = '${COMMONLY_TOKEN_FILE}';
28
+
29
+ /**
30
+ * What a runtime's OWN environment may carry, once the declarations are settled.
31
+ *
32
+ * The rewrite above decides what a CHILD is told. It does not decide what the
33
+ * runtime process itself carries, and that is a separate leak with the same
34
+ * symptom: the credential is exported for bootstrap (`agent run`, the daemon),
35
+ * so an adapter that derives its runtime environment from `process.env` hands
36
+ * the value back to the runtime — and to every MCP child, hook and shell below
37
+ * it — however the declaration was rewritten. Measured (Vera, 70455): four tests
38
+ * that pass in a runner without the variable fail with it set, and the value
39
+ * they saw was the runner's own.
40
+ *
41
+ * So the PATH of this spawn's file goes in (a path is not a secret, and a hook
42
+ * process resolves its credential from it — see `hooks-config.resolveHookToken`)
43
+ * and the VALUE comes out, unless a carve-out genuinely needs the value here:
44
+ * a field the adapter substitutes LITERALLY has no file channel, so `keepsValue`
45
+ * is passed in by the adapter rather than inferred, and the spawn that keeps a
46
+ * secret says so in its own warning.
47
+ */
48
+ export const withholdRuntimeCredential = (env, { credentialFile = null, keepsValue = false } = {}) => {
49
+ if (credentialFile) env[CREDENTIAL_FILE_VAR] = credentialFile;
50
+ if (!keepsValue) delete env[CREDENTIAL_KEY];
51
+ return env;
52
+ };
53
+
54
+ /** What a declaration says when it asks for the seat credential (the old shape). */
55
+ export const CREDENTIAL_PLACEHOLDER = '${COMMONLY_AGENT_TOKEN}';
56
+
57
+ /**
58
+ * Rewrite one server's declared environment so the credential arrives as a path.
59
+ *
60
+ * Returns `{ env, delivered }` where `delivered` is one of:
61
+ * 'path' — the declaration now names the file; the adapter expands it
62
+ * 'env' — the value stays in the environment, deliberately (see below)
63
+ * 'none' — nothing to deliver: the entry declares no credential
64
+ * 'unavailable' — no launcher credential file exists for this spawn
65
+ *
66
+ * `env` is a fresh object; the caller's declaration is never mutated.
67
+ */
68
+ export const deliverSeatCredential = (server, {
69
+ credentialFile,
70
+ onWarn = (message) => process.stderr.write(`${message}\n`),
71
+ label = 'mcp',
72
+ } = {}) => {
73
+ const env = { ...((server || {}).env || {}) };
74
+ if (env[CREDENTIAL_FILE_VAR] !== undefined && String(env[CREDENTIAL_FILE_VAR]).trim() !== '') {
75
+ // Already on the launcher channel — an operator who set this by hand, or a
76
+ // record written after this shipped. Nothing to rewrite.
77
+ return { env, delivered: 'path' };
78
+ }
79
+ if (env[CREDENTIAL_KEY] === undefined) return { env, delivered: 'none' };
80
+ if (!credentialFile) {
81
+ // No launcher wrote a file, so there is nothing to point at. Leave the
82
+ // declaration exactly as it was rather than handing over a path to nowhere.
83
+ return { env, delivered: 'unavailable' };
84
+ }
85
+ const ours = describeMcpCommand((server || {}).command);
86
+ if (!ours) {
87
+ // Somebody else's server. Their declaration is theirs: we do not know their
88
+ // protocol, so replacing their variable with a path would break a server we
89
+ // have no business redefining.
90
+ onWarn(`[${label}] ${server?.name} is not ${MCP_PACKAGE} but declares ${CREDENTIAL_KEY}; leaving that declaration alone. Declare the seat credential on the commonly entry instead.`);
91
+ return { env, delivered: 'env' };
92
+ }
93
+ if (versionOlderThan(ours.version, FILE_READER_VERSION) === true) {
94
+ // Measured, not hypothetical: five seats run a hand-patched staging checkout
95
+ // at 0.3.7, whose reader only understands the environment. Handing it a path
96
+ // it cannot read would take its tools away rather than its secret.
97
+ onWarn(`[${label}] ${server.name} runs @commonlyai/mcp ${ours.version.join('.')}, which predates the credential file (${FILE_READER_VERSION.join('.')}): keeping the token in the environment. Unpin it, or move that seat off this checkout.`);
98
+ return { env, delivered: 'env' };
99
+ }
100
+ if (String(env[CREDENTIAL_KEY]) !== CREDENTIAL_PLACEHOLDER) {
101
+ // A literal token in a declaration is stale by construction — seat tokens
102
+ // rotate, and this one was read when the record was written. Superseding it
103
+ // with the live credential is a repair, but it IS a change in what the seat
104
+ // sends, so it is said out loud rather than done quietly.
105
+ onWarn(`[${label}] ${server.name} declares a literal ${CREDENTIAL_KEY}; superseding it with this spawn's credential file.`);
106
+ }
107
+ delete env[CREDENTIAL_KEY];
108
+ env[CREDENTIAL_FILE_VAR] = CREDENTIAL_FILE_PLACEHOLDER;
109
+ return { env, delivered: 'path' };
110
+ };
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Which `@commonlyai/mcp` a declared MCP command would run, and which channels
3
+ * that release understands.
4
+ *
5
+ * Shared by the pi bridge (which decides whether to pipe a credential) and by
6
+ * the claude and codex adapters (which decide whether to hand over a PATH or the
7
+ * value): one predicate, because three copies of "is this old enough" would
8
+ * drift, and the drift would be silent in exactly one adapter.
9
+ */
10
+ import { existsSync, readFileSync } from 'node:fs';
11
+ import { dirname, join } from 'node:path';
12
+
13
+ export const MCP_PACKAGE = '@commonlyai/mcp';
14
+
15
+ /** The release whose reader accepts the credential on an inherited fd. */
16
+ export const PIPE_READER_VERSION = [0, 3, 11];
17
+
18
+ /** The release whose reader accepts `COMMONLY_TOKEN_FILE` (a PATH, not a secret). */
19
+ export const FILE_READER_VERSION = [0, 3, 12];
20
+
21
+ export const parseVersion = (spec) => {
22
+ const match = /^(\d+)\.(\d+)\.(\d+)/.exec(String(spec || '').trim());
23
+ return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null;
24
+ };
25
+
26
+ /**
27
+ * True when `version` is older than `target`; null when there is no version to
28
+ * judge (an unpinned `@latest` tracks the published release, so it is never
29
+ * treated as old).
30
+ */
31
+ export const versionOlderThan = (version, target) => {
32
+ if (!version) return null;
33
+ for (let i = 0; i < 3; i += 1) {
34
+ if (version[i] !== target[i]) return version[i] < target[i];
35
+ }
36
+ return false;
37
+ };
38
+
39
+ /**
40
+ * What an `@commonlyai/mcp` command would run, or null when the command cannot
41
+ * be identified as that package at all.
42
+ *
43
+ * Two shapes matter: `npx [-y] @commonlyai/mcp@<spec>` (a spec is a version, or
44
+ * `latest`/absent, which resolves to whatever is published — never treated as
45
+ * old), and a local checkout, `node <path>/src/index.js`, which is what the
46
+ * staging seats run; for that one the package.json beside it is the only honest
47
+ * answer, and a package.json naming something else means this is not our server.
48
+ *
49
+ * `{ isCommonly: true, version: null }` means "our server, version unknown" —
50
+ * an unpinned npx spec, whose whole point is that it tracks the published one.
51
+ * `null` as the return value means "not identifiable as our server", which is a
52
+ * different answer and takes a different branch: a stranger's server gets its
53
+ * declaration honoured unchanged.
54
+ */
55
+ export const describeMcpCommand = (command, {
56
+ readTextFile = (p) => (existsSync(p) ? readFileSync(p, 'utf8') : null),
57
+ } = {}) => {
58
+ if (!Array.isArray(command) || command.length === 0) return null;
59
+ const parts = command.map(String);
60
+ const pkgArg = parts.find((p) => p.includes(MCP_PACKAGE));
61
+ if (pkgArg) {
62
+ const at = pkgArg.lastIndexOf('@');
63
+ if (at <= pkgArg.indexOf(MCP_PACKAGE)) return { isCommonly: true, version: null };
64
+ return { isCommonly: true, version: parseVersion(pkgArg.slice(at + 1)) };
65
+ }
66
+ const scriptPath = parts.find((p) => p.endsWith('.js') || p.endsWith('.mjs'));
67
+ if (!scriptPath) return null;
68
+ // `src/index.js` → `../package.json`; also try one level further up, because a
69
+ // bin shim can live in `bin/` beside `src/`.
70
+ for (const candidate of [join(dirname(scriptPath), '..', 'package.json'), join(dirname(scriptPath), 'package.json')]) {
71
+ let raw;
72
+ try {
73
+ raw = readTextFile(candidate);
74
+ } catch {
75
+ raw = null;
76
+ }
77
+ if (!raw) continue;
78
+ try {
79
+ const pkg = JSON.parse(raw);
80
+ if (!pkg || typeof pkg !== 'object') continue;
81
+ if (pkg.name === MCP_PACKAGE) return { isCommonly: true, version: parseVersion(pkg.version) };
82
+ // A package.json that names another package settles it: not ours, so its
83
+ // declaration is none of this function's business.
84
+ return null;
85
+ } catch {
86
+ // A malformed package.json is not an answer; keep looking.
87
+ }
88
+ }
89
+ return null;
90
+ };