@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.
- package/package.json +1 -1
- package/src/commands/agent.js +9 -1
- package/src/lib/adapters/claude.js +71 -12
- package/src/lib/adapters/codex.js +72 -2
- package/src/lib/adapters/pi-mcp-client.mjs +113 -80
- package/src/lib/adapters/pi.js +64 -22
- package/src/lib/credential-file.js +78 -0
- package/src/lib/daemon-supervisor.js +26 -4
- package/src/lib/grant-broker-guard.js +268 -0
- package/src/lib/hooks-config.js +32 -1
- package/src/lib/mcp-credential-delivery.js +110 -0
- package/src/lib/mcp-server-version.js +90 -0
|
@@ -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
|
+
};
|