@cairnkeep/cli 2.24.0 → 2.25.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/CHANGELOG.md +24 -0
- package/docs/README.md +4 -0
- package/docs/agents.md +18 -0
- package/docs/design/team-mode.md +17 -9
- package/docs/harness-compatibility.md +17 -0
- package/docs/learning/CURRICULUM-MAP.md +3 -1
- package/docs/learning/FEATURE-GUIDE.md +29 -3
- package/docs/learning/README.md +5 -3
- package/docs/learning/lessons/L00-why-cairnkeep.md +1 -1
- package/docs/learning/lessons/L01-safe-trial.md +3 -3
- package/docs/learning/lessons/L02-installation.md +2 -2
- package/docs/learning/lessons/L03-first-project.md +1 -1
- package/docs/learning/lessons/L19-validated-skill-improvement.md +1 -1
- package/docs/learning/lessons/L20-mcp-tool-profiles.md +1 -1
- package/docs/learning/lessons/L21-context-packs.md +1 -1
- package/docs/learning/lessons/L22-native-windows.md +2 -2
- package/docs/learning/lessons/L23-guided-setup.md +1 -1
- package/docs/learning/lessons/L24-okf-exchange.md +1 -1
- package/docs/learning/lessons/L25-playbooks.md +1 -1
- package/docs/learning/lessons/L26-context-intelligence.md +1 -1
- package/docs/learning/lessons/L27-ecosystem-evidence.md +1 -1
- package/docs/learning/lessons/L28-security-assurance.md +2 -2
- package/docs/learning/lessons/L29-team-continuity.md +95 -0
- package/docs/learning/tracks/operator.md +2 -0
- package/docs/learning/video-scripts/V29-team-continuity.md +66 -0
- package/docs/operating.md +17 -3
- package/docs/privacy-and-data-flow.md +24 -5
- package/docs/security-assurance.md +28 -0
- package/docs/storage.md +18 -0
- package/docs/team-pilot.md +132 -0
- package/docs/team.md +234 -0
- package/examples/eval/bundled-fake.json +1 -1
- package/mcp-memory-server/dist/team-cli.js +218 -0
- package/mcp-memory-server/dist/team-http.js +193 -0
- package/mcp-memory-server/dist/team-mcp.js +82 -0
- package/mcp-memory-server/dist/team-store.js +663 -0
- package/package.json +1 -1
- package/scripts/cairn-cli.mjs +2 -0
- package/scripts/completion.sh +23 -3
- package/scripts/lib/team-retention.mjs +29 -0
- package/scripts/test-ci-sharding.sh +4 -0
- package/scripts/test-completion.sh +8 -0
- package/scripts/test-package-install.sh +9 -0
- package/scripts/test-team-docs.sh +52 -0
- package/scripts/test-team-pilot.mjs +59 -0
- package/scripts/uninstall.sh +13 -0
- package/scripts/verify-team-retention.mjs +3 -0
- package/scripts/windows-platform.mjs +6 -2
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { closeSync, existsSync, openSync, writeFileSync, fsyncSync } from 'node:fs';
|
|
2
|
+
import { dirname, join, resolve } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
5
|
+
import { readStableJson } from './stable-file.js';
|
|
6
|
+
import { hardenPrivatePath } from './platform-security.js';
|
|
7
|
+
import { openTeamStore, restoreTeamSnapshot, verifyTeamSnapshot, prepareTeamPrivateDirectory, TEAM_SNAPSHOT_MAX_BYTES, TeamError } from './team-store.js';
|
|
8
|
+
import { createTeamHttpServer, teamRequest } from './team-http.js';
|
|
9
|
+
import { createTeamMcpServer, teamClientToken } from './team-mcp.js';
|
|
10
|
+
const HELP = `cairn team — experimental token-first team continuity (default off)
|
|
11
|
+
|
|
12
|
+
CAIRN_TEAM=1 is required. Personal memory is never uploaded.
|
|
13
|
+
|
|
14
|
+
cairn team init --organization ID [--data DIR]
|
|
15
|
+
cairn team project create ID [--data DIR]
|
|
16
|
+
cairn team member set SUBJECT --project ID --roles LIST [--data DIR]
|
|
17
|
+
cairn team member remove SUBJECT --project ID [--data DIR]
|
|
18
|
+
cairn team credential issue SUBJECT --class human|workload --expires-at ISO --output FILE [--data DIR]
|
|
19
|
+
cairn team credential revoke ID [--data DIR]
|
|
20
|
+
cairn team serve [--data DIR] [--port PORT] # requires CAIRN_TEAM_HTTP=1; loopback only
|
|
21
|
+
cairn team mcp [--project-root PATH] # local stdio MCP; no review/admin tools
|
|
22
|
+
cairn team list --project ID
|
|
23
|
+
cairn team proposals --project ID
|
|
24
|
+
cairn team audit --project ID # scoped, value-free metadata
|
|
25
|
+
cairn team read --project ID --key KEY [--revision DIGEST]
|
|
26
|
+
cairn team history --project ID --key KEY
|
|
27
|
+
cairn team search --project ID --query TEXT
|
|
28
|
+
cairn team proposal-show --project ID --proposal ID
|
|
29
|
+
cairn team propose --project ID --input FILE --confirm VALUE_DIGEST
|
|
30
|
+
cairn team review --project ID --proposal ID --decision approve|reject --confirm PROPOSAL_DIGEST
|
|
31
|
+
cairn team doctor [--data DIR]
|
|
32
|
+
cairn team backup --output FILE [--data DIR]
|
|
33
|
+
cairn team restore --input FILE --confirm SNAPSHOT_DIGEST --data NEW_DIR
|
|
34
|
+
cairn team memory-delete --project ID --key KEY --confirm REVISION_DIGEST [--data DIR]
|
|
35
|
+
|
|
36
|
+
Clients require CAIRN_TEAM_URL, CAIRN_TEAM_ORGANIZATION and CAIRN_TEAM_TOKEN_FILE.
|
|
37
|
+
Never put tokens in command arguments, source trees or harness config.
|
|
38
|
+
Team API is not OAuth MCP HTTP. Independent production security review and a
|
|
39
|
+
deployment-specific approved real-data pilot remain admission gates. See docs/team.md.
|
|
40
|
+
`;
|
|
41
|
+
function parse(args) {
|
|
42
|
+
const words = [];
|
|
43
|
+
const flags = {};
|
|
44
|
+
const known = new Set(['data', 'organization', 'project', 'roles', 'class', 'expires-at', 'output', 'port', 'project-root', 'key', 'revision', 'query', 'proposal', 'input', 'confirm', 'decision']);
|
|
45
|
+
for (let i = 0; i < args.length; i++) {
|
|
46
|
+
const arg = args[i];
|
|
47
|
+
if (arg.startsWith('--')) {
|
|
48
|
+
const name = arg.slice(2);
|
|
49
|
+
if (!known.has(name) || flags[name] !== undefined || !args[i + 1] || args[i + 1].startsWith('--'))
|
|
50
|
+
throw new TeamError('invalid');
|
|
51
|
+
flags[name] = args[++i];
|
|
52
|
+
}
|
|
53
|
+
else
|
|
54
|
+
words.push(arg);
|
|
55
|
+
}
|
|
56
|
+
return { words, flags };
|
|
57
|
+
}
|
|
58
|
+
export function writePrivateExclusive(path, bytes) {
|
|
59
|
+
path = resolve(path);
|
|
60
|
+
const directory = dirname(path);
|
|
61
|
+
prepareTeamPrivateDirectory(directory, true);
|
|
62
|
+
const fd = openSync(path, 'wx', 0o600);
|
|
63
|
+
try {
|
|
64
|
+
hardenPrivatePath(path);
|
|
65
|
+
writeFileSync(fd, bytes, 'utf8');
|
|
66
|
+
fsyncSync(fd);
|
|
67
|
+
}
|
|
68
|
+
finally {
|
|
69
|
+
closeSync(fd);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
async function main(args) {
|
|
73
|
+
if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
|
|
74
|
+
console.log(HELP);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (process.env.CAIRN_TEAM !== '1')
|
|
78
|
+
throw new TeamError('disabled');
|
|
79
|
+
const { words, flags } = parse(args);
|
|
80
|
+
const [command, subcommand, subject] = words;
|
|
81
|
+
const allowed = {
|
|
82
|
+
init: ['data', 'organization'], project: ['data'], member: subcommand === 'remove' ? ['data', 'project'] : ['data', 'project', 'roles'],
|
|
83
|
+
credential: subcommand === 'issue' ? ['data', 'class', 'expires-at', 'output'] : ['data'],
|
|
84
|
+
serve: ['data', 'port'], mcp: ['project-root'], doctor: ['data'], backup: ['data', 'output'],
|
|
85
|
+
restore: ['data', 'input', 'confirm'], 'memory-delete': ['data', 'project', 'key', 'confirm'],
|
|
86
|
+
list: ['project'], proposals: ['project'], audit: ['project'], read: ['project', 'key', 'revision'],
|
|
87
|
+
history: ['project', 'key'], search: ['project', 'query'], 'proposal-show': ['project', 'proposal'],
|
|
88
|
+
propose: ['project', 'input', 'confirm'], review: ['project', 'proposal', 'decision', 'confirm'],
|
|
89
|
+
};
|
|
90
|
+
if (!allowed[command] || Object.keys(flags).some(flag => !allowed[command].includes(flag)))
|
|
91
|
+
throw new TeamError('invalid');
|
|
92
|
+
const memberRoles = (flags.roles ?? '').split(',').filter(Boolean);
|
|
93
|
+
if (command === 'member' && (!['set', 'remove'].includes(subcommand) || !subject || words.length !== 3 || !flags.project || (subcommand === 'set' && memberRoles.length === 0)))
|
|
94
|
+
throw new TeamError('invalid');
|
|
95
|
+
const root = resolve(flags.data ?? process.env.CAIRN_TEAM_BASE_DIR ?? join(homedir(), '.cairnkeep', 'team'));
|
|
96
|
+
const print = (result) => console.log(JSON.stringify(result, null, 2));
|
|
97
|
+
let store;
|
|
98
|
+
try {
|
|
99
|
+
if (command === 'mcp') {
|
|
100
|
+
if (words.length !== 1)
|
|
101
|
+
throw new TeamError('invalid');
|
|
102
|
+
teamClientToken(); // validate private credential before startup
|
|
103
|
+
const server = await createTeamMcpServer({ url: process.env.CAIRN_TEAM_URL ?? '', organization: process.env.CAIRN_TEAM_ORGANIZATION ?? '', token: () => teamClientToken(), projectRoot: flags['project-root'] });
|
|
104
|
+
await server.connect(new StdioServerTransport());
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (command === 'restore') {
|
|
108
|
+
if (words.length !== 1 || !flags.input || !flags.confirm || !flags.data)
|
|
109
|
+
throw new TeamError('invalid');
|
|
110
|
+
const snapshot = verifyTeamSnapshot(readStableJson(resolve(flags.input), { label: 'Team snapshot', maxBytes: TEAM_SNAPSHOT_MAX_BYTES, private: true }));
|
|
111
|
+
store = await restoreTeamSnapshot(root, snapshot, flags.confirm);
|
|
112
|
+
print({ restored: true, organization: store.organization, credentials_revoked: true });
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (['init', 'project', 'member', 'credential', 'serve', 'doctor', 'backup', 'memory-delete'].includes(command)) {
|
|
116
|
+
if (command === 'init' && (words.length !== 1 || !flags.organization))
|
|
117
|
+
throw new TeamError('invalid');
|
|
118
|
+
store = await openTeamStore(root, command === 'init' ? { create: true, organization: flags.organization } : {});
|
|
119
|
+
if (command === 'doctor' && words.length === 1) {
|
|
120
|
+
print(await store.doctor());
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (command === 'backup' && words.length === 1) {
|
|
124
|
+
if (!flags.output)
|
|
125
|
+
throw new TeamError('invalid');
|
|
126
|
+
const snapshot = await store.snapshot();
|
|
127
|
+
writePrivateExclusive(flags.output, `${JSON.stringify(snapshot)}\n`);
|
|
128
|
+
print({ snapshot_digest: snapshot.digest, written: true });
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
if (command === 'memory-delete' && words.length === 1) {
|
|
132
|
+
await store.admin({ operation: 'memory-delete', project: flags.project, key: flags.key, confirm: flags.confirm });
|
|
133
|
+
print({ deleted: true, backup_copies_retained: true });
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (command === 'init') {
|
|
137
|
+
print({ initialized: true, organization: store.organization, experimental: true });
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (command === 'project' && subcommand === 'create' && subject && words.length === 3) {
|
|
141
|
+
await store.admin({ operation: 'project-create', project: subject });
|
|
142
|
+
print({ created: true });
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (command === 'member') {
|
|
146
|
+
await store.admin({ operation: 'member-set', subject, project: flags.project, roles: subcommand === 'remove' ? [] : memberRoles });
|
|
147
|
+
print(subcommand === 'remove' ? { removed: true } : { updated: true });
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
if (command === 'credential' && subcommand === 'issue' && subject && words.length === 3) {
|
|
151
|
+
if (!flags.output || existsSync(resolve(flags.output)))
|
|
152
|
+
throw new TeamError('invalid');
|
|
153
|
+
const credential = await store.issue({ subject, credential_class: flags.class, expires_at: flags['expires-at'] });
|
|
154
|
+
try {
|
|
155
|
+
writePrivateExclusive(flags.output, `${credential.token}\n`);
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
await store.admin({ operation: 'credential-revoke', credential: credential.id });
|
|
159
|
+
throw error;
|
|
160
|
+
}
|
|
161
|
+
print({ credential_id: credential.id, token_file_created: true });
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if (command === 'credential' && subcommand === 'revoke' && subject && words.length === 3) {
|
|
165
|
+
await store.admin({ operation: 'credential-revoke', credential: subject });
|
|
166
|
+
print({ revoked: true });
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (command === 'serve' && words.length === 1) {
|
|
170
|
+
const raw = flags.port ?? '7955';
|
|
171
|
+
if (!/^[1-9][0-9]{0,4}$/.test(raw) || Number(raw) > 65535)
|
|
172
|
+
throw new TeamError('invalid');
|
|
173
|
+
const server = createTeamHttpServer(store);
|
|
174
|
+
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(Number(raw), '127.0.0.1', resolve); });
|
|
175
|
+
process.stderr.write(`Team preview API listening on loopback port ${raw}.\n`);
|
|
176
|
+
await new Promise(resolve => {
|
|
177
|
+
const stop = () => { server.close(() => resolve()); server.closeAllConnections(); };
|
|
178
|
+
process.once('SIGINT', stop);
|
|
179
|
+
process.once('SIGTERM', stop);
|
|
180
|
+
});
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
throw new TeamError('invalid');
|
|
184
|
+
}
|
|
185
|
+
if (words.length !== 1 || !flags.project)
|
|
186
|
+
throw new TeamError('invalid');
|
|
187
|
+
const common = { organization: process.env.CAIRN_TEAM_ORGANIZATION, project: flags.project, operation: command };
|
|
188
|
+
let input;
|
|
189
|
+
if (['list', 'proposals', 'audit'].includes(command))
|
|
190
|
+
input = common;
|
|
191
|
+
else if (['read', 'history'].includes(command))
|
|
192
|
+
input = { ...common, key: flags.key, ...(flags.revision ? { revision: flags.revision } : {}) };
|
|
193
|
+
else if (command === 'search')
|
|
194
|
+
input = { ...common, query: flags.query };
|
|
195
|
+
else if (command === 'proposal-show')
|
|
196
|
+
input = { ...common, proposal_id: flags.proposal };
|
|
197
|
+
else if (command === 'review')
|
|
198
|
+
input = { ...common, proposal_id: flags.proposal, decision: flags.decision, confirm: flags.confirm };
|
|
199
|
+
else if (command === 'propose') {
|
|
200
|
+
if (!flags.input || !flags.confirm)
|
|
201
|
+
throw new TeamError('invalid');
|
|
202
|
+
const selected = readStableJson(resolve(flags.input), { label: 'Team selected proposal', maxBytes: 512 * 1024, private: true });
|
|
203
|
+
if (!selected || typeof selected !== 'object' || Array.isArray(selected))
|
|
204
|
+
throw new TeamError('invalid');
|
|
205
|
+
input = { ...selected, ...common, confirm: flags.confirm };
|
|
206
|
+
}
|
|
207
|
+
else
|
|
208
|
+
throw new TeamError('invalid');
|
|
209
|
+
print(await teamRequest(process.env.CAIRN_TEAM_URL ?? '', teamClientToken(), input));
|
|
210
|
+
}
|
|
211
|
+
finally {
|
|
212
|
+
await store?.close();
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
void main(process.argv.slice(2)).catch(error => {
|
|
216
|
+
process.stderr.write(`${error instanceof TeamError ? error.message : 'Team operation failed; no credential or input content is logged.'}\n`);
|
|
217
|
+
process.exitCode = 1;
|
|
218
|
+
});
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { normalizeHostAuthority } from './http-security.js';
|
|
3
|
+
import { TeamError, teamDigest } from './team-store.js';
|
|
4
|
+
const MAX_BODY = 512 * 1024;
|
|
5
|
+
const MAX_RESPONSE = 2 * 1024 * 1024;
|
|
6
|
+
function reply(response, status, body) {
|
|
7
|
+
const bytes = Buffer.from(JSON.stringify(body));
|
|
8
|
+
if (bytes.byteLength > MAX_RESPONSE) {
|
|
9
|
+
reply(response, 503, { error: 'unavailable' });
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
response.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff', 'Content-Length': bytes.byteLength, ...(status >= 400 ? { Connection: 'close' } : {}) });
|
|
13
|
+
response.end(bytes);
|
|
14
|
+
}
|
|
15
|
+
async function body(request) {
|
|
16
|
+
if (request.headers['content-type'] !== 'application/json')
|
|
17
|
+
throw new TeamError('invalid');
|
|
18
|
+
const chunks = [];
|
|
19
|
+
await new Promise((resolve, reject) => {
|
|
20
|
+
let size = 0;
|
|
21
|
+
const cleanup = () => { request.off('data', onData); request.off('end', onEnd); request.off('error', onError); request.off('aborted', onError); };
|
|
22
|
+
const onError = () => { cleanup(); reject(new TeamError('invalid')); };
|
|
23
|
+
const onEnd = () => { cleanup(); resolve(); };
|
|
24
|
+
const onData = (chunk) => {
|
|
25
|
+
size += chunk.byteLength;
|
|
26
|
+
if (size > MAX_BODY) {
|
|
27
|
+
cleanup();
|
|
28
|
+
request.pause();
|
|
29
|
+
reject(new RangeError('body-limit'));
|
|
30
|
+
}
|
|
31
|
+
else
|
|
32
|
+
chunks.push(chunk);
|
|
33
|
+
};
|
|
34
|
+
request.on('data', onData);
|
|
35
|
+
request.once('end', onEnd);
|
|
36
|
+
request.once('error', onError);
|
|
37
|
+
request.once('aborted', onError);
|
|
38
|
+
});
|
|
39
|
+
try {
|
|
40
|
+
return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks)));
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
throw new TeamError('invalid');
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/** This is a token-first JSON API, not OAuth-authorized remote MCP. It only
|
|
47
|
+
* listens on loopback; use an authenticated encrypted tunnel for remote use. */
|
|
48
|
+
export function createTeamHttpServer(store) {
|
|
49
|
+
if (process.env.CAIRN_TEAM !== '1' || process.env.CAIRN_TEAM_HTTP !== '1')
|
|
50
|
+
throw new TeamError('disabled');
|
|
51
|
+
const fixedWindow = (requests) => {
|
|
52
|
+
const limits = new Map();
|
|
53
|
+
return (key) => {
|
|
54
|
+
const now = Date.now();
|
|
55
|
+
for (const [id, state] of limits)
|
|
56
|
+
if (now - state.start >= 60000)
|
|
57
|
+
limits.delete(id);
|
|
58
|
+
let state = limits.get(key);
|
|
59
|
+
if (!state) {
|
|
60
|
+
if (limits.size >= 512)
|
|
61
|
+
return false;
|
|
62
|
+
limits.set(key, state = { start: now, count: 0 });
|
|
63
|
+
}
|
|
64
|
+
return ++state.count <= requests;
|
|
65
|
+
};
|
|
66
|
+
};
|
|
67
|
+
// Invalid authentication must remain bounded without consuming the scarce
|
|
68
|
+
// quota of an authenticated subject that happens to share loopback/tunnel.
|
|
69
|
+
const malformedAuthAdmitted = fixedWindow(120);
|
|
70
|
+
const invalidCredentialAdmitted = fixedWindow(10);
|
|
71
|
+
const subjectAdmitted = fixedWindow(120);
|
|
72
|
+
const server = createServer({ maxHeaderSize: 32 * 1024 }, (request, response) => {
|
|
73
|
+
void (async () => {
|
|
74
|
+
// Reject browser access and host rebinding before reading any body.
|
|
75
|
+
const authority = normalizeHostAuthority(request.headers.host);
|
|
76
|
+
const port = request.socket.localPort;
|
|
77
|
+
if (!['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(request.socket.localAddress ?? '') || !authority || ![`127.0.0.1:${port}`, `localhost:${port}`, `[::1]:${port}`].includes(authority) || request.headers.origin !== undefined) {
|
|
78
|
+
reply(response, 403, { error: 'denied' });
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (request.url !== '/v1/team/execute') {
|
|
82
|
+
reply(response, 404, { error: 'not-found' });
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (request.method !== 'POST') {
|
|
86
|
+
reply(response, 405, { error: 'method' });
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const authHeaders = request.rawHeaders.filter((_, i) => i % 2 === 0 && request.rawHeaders[i].toLowerCase() === 'authorization');
|
|
90
|
+
const token = /^Bearer ([A-Za-z0-9_-]{43})$/.exec(request.headers.authorization ?? '')?.[1];
|
|
91
|
+
if (!token || authHeaders.length !== 1) {
|
|
92
|
+
const key = teamDigest(`malformed:${request.socket.remoteAddress ?? ''}`);
|
|
93
|
+
const admitted = malformedAuthAdmitted(key);
|
|
94
|
+
reply(response, admitted ? 401 : 429, { error: admitted ? 'unauthorized' : 'rate-limit' });
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
let identity;
|
|
98
|
+
try {
|
|
99
|
+
identity = await store.checkCredential(token);
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
if (!(error instanceof TeamError) || error.code !== 'unauthorized')
|
|
103
|
+
throw error;
|
|
104
|
+
const admitted = invalidCredentialAdmitted(teamDigest(`credential:${token}`));
|
|
105
|
+
reply(response, admitted ? 401 : 429, { error: admitted ? 'unauthorized' : 'rate-limit' });
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (!subjectAdmitted(teamDigest(identity.subject))) {
|
|
109
|
+
reply(response, 429, { error: 'rate-limit' });
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const length = request.headers['content-length'];
|
|
113
|
+
if (length && (!/^[0-9]+$/.test(length) || Number(length) > MAX_BODY)) {
|
|
114
|
+
reply(response, 413, { error: 'body-limit' });
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
request.setTimeout(10000, () => request.destroy());
|
|
118
|
+
const input = await body(request);
|
|
119
|
+
const result = await store.execute(token, input); // reauthorize after body receipt
|
|
120
|
+
reply(response, 200, { result });
|
|
121
|
+
})().catch(error => {
|
|
122
|
+
const status = error instanceof TeamError ? ({ unauthorized: 401, denied: 403, invalid: 400, conflict: 409, disabled: 503, integrity: 503 }[error.code]) : error instanceof RangeError ? 413 : 503;
|
|
123
|
+
if (!response.headersSent && !response.destroyed)
|
|
124
|
+
reply(response, status, { error: error instanceof TeamError ? error.code : status === 413 ? 'body-limit' : 'unavailable' });
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
server.headersTimeout = 10000;
|
|
128
|
+
server.requestTimeout = 30000;
|
|
129
|
+
server.keepAliveTimeout = 5000;
|
|
130
|
+
server.maxRequestsPerSocket = 100;
|
|
131
|
+
server.on('clientError', (_error, socket) => { if (socket.writable)
|
|
132
|
+
socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\nContent-Length: 0\r\n\r\n'); });
|
|
133
|
+
return server;
|
|
134
|
+
}
|
|
135
|
+
export function validateTeamUrl(raw) {
|
|
136
|
+
let url;
|
|
137
|
+
try {
|
|
138
|
+
url = new URL(raw);
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
throw new TeamError('invalid');
|
|
142
|
+
}
|
|
143
|
+
const loopback = ['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname);
|
|
144
|
+
if (url.username || url.password || url.search || url.hash || url.pathname !== '/' || (url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)))
|
|
145
|
+
throw new TeamError('invalid');
|
|
146
|
+
return url;
|
|
147
|
+
}
|
|
148
|
+
export async function teamRequest(rawUrl, token, input) {
|
|
149
|
+
if (process.env.CAIRN_TEAM !== '1')
|
|
150
|
+
throw new TeamError('disabled');
|
|
151
|
+
const url = validateTeamUrl(rawUrl);
|
|
152
|
+
if (!/^[A-Za-z0-9_-]{43}$/.test(token))
|
|
153
|
+
throw new TeamError('unauthorized');
|
|
154
|
+
const bytes = JSON.stringify(input);
|
|
155
|
+
if (Buffer.byteLength(bytes) > MAX_BODY)
|
|
156
|
+
throw new TeamError('invalid');
|
|
157
|
+
try {
|
|
158
|
+
const response = await fetch(new URL('/v1/team/execute', url), { method: 'POST', redirect: 'error', signal: AbortSignal.timeout(15000), headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, body: bytes });
|
|
159
|
+
const errorCode = response.status === 401 ? 'unauthorized' : response.status === 403 ? 'denied' : response.status === 409 ? 'conflict' : response.status === 400 ? 'invalid' : 'integrity';
|
|
160
|
+
if (!response.ok) {
|
|
161
|
+
await response.body?.cancel();
|
|
162
|
+
throw new TeamError(errorCode);
|
|
163
|
+
}
|
|
164
|
+
const chunks = [];
|
|
165
|
+
let size = 0;
|
|
166
|
+
if (!response.body)
|
|
167
|
+
throw new TeamError('integrity');
|
|
168
|
+
const reader = response.body.getReader();
|
|
169
|
+
try {
|
|
170
|
+
while (true) {
|
|
171
|
+
const { done, value } = await reader.read();
|
|
172
|
+
if (done)
|
|
173
|
+
break;
|
|
174
|
+
size += value.byteLength;
|
|
175
|
+
if (size > MAX_RESPONSE)
|
|
176
|
+
throw new TeamError('integrity');
|
|
177
|
+
chunks.push(value);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
finally {
|
|
181
|
+
await reader.cancel().catch(() => undefined);
|
|
182
|
+
}
|
|
183
|
+
const parsed = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks)));
|
|
184
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed) || Object.keys(parsed).length !== 1 || !Object.hasOwn(parsed, 'result'))
|
|
185
|
+
throw new TeamError('integrity');
|
|
186
|
+
return parsed.result;
|
|
187
|
+
}
|
|
188
|
+
catch (error) {
|
|
189
|
+
if (error instanceof TeamError)
|
|
190
|
+
throw error;
|
|
191
|
+
throw new TeamError('integrity');
|
|
192
|
+
}
|
|
193
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { resolveMcpToolProfile, profileAllowsTool } from './mcp-tool-profile.js';
|
|
4
|
+
import { isCapabilityContractEnabled, resolveCapabilityStatus } from './capability-config.js';
|
|
5
|
+
import { teamRequest, validateTeamUrl } from './team-http.js';
|
|
6
|
+
import { TeamError, teamDigest } from './team-store.js';
|
|
7
|
+
import { resolveHttpToken } from './http-security.js';
|
|
8
|
+
const observation = (title) => ({ title, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true } });
|
|
9
|
+
// The bridge crosses the local process boundary into a remote team service;
|
|
10
|
+
// openWorldHint is true even though team retrieval is deterministic and closed
|
|
11
|
+
// to model providers. No review or membership tool is exposed here.
|
|
12
|
+
export const TEAM_TOOL_CATALOG = {
|
|
13
|
+
team_memory_list: observation('List approved team memory'),
|
|
14
|
+
team_memory_read: observation('Read approved team memory'),
|
|
15
|
+
team_memory_search: observation('Search approved team memory'),
|
|
16
|
+
team_memory_history: observation('Read team memory history'),
|
|
17
|
+
team_memory_propose: { title: 'Propose selected team memory', annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true } },
|
|
18
|
+
};
|
|
19
|
+
const coreNames = { team_memory_list: 'memory_list', team_memory_read: 'memory_read', team_memory_search: 'memory_search', team_memory_history: 'memory_history', team_memory_propose: 'memory_write' };
|
|
20
|
+
export function teamClientToken(env = process.env) {
|
|
21
|
+
// A file-only credential keeps raw secrets out of harness configuration.
|
|
22
|
+
if (!env.CAIRN_TEAM_TOKEN_FILE)
|
|
23
|
+
throw new TeamError('unauthorized');
|
|
24
|
+
const resolved = resolveHttpToken({ CAIRN_MEMORY_HTTP_TOKEN_FILE: env.CAIRN_TEAM_TOKEN_FILE });
|
|
25
|
+
if (!resolved.ok || !/^[A-Za-z0-9_-]{43}$/.test(resolved.token))
|
|
26
|
+
throw new TeamError('unauthorized');
|
|
27
|
+
return resolved.token;
|
|
28
|
+
}
|
|
29
|
+
export async function createTeamMcpServer(options) {
|
|
30
|
+
if (process.env.CAIRN_TEAM !== '1')
|
|
31
|
+
throw new TeamError('disabled');
|
|
32
|
+
validateTeamUrl(options.url);
|
|
33
|
+
if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(options.organization))
|
|
34
|
+
throw new TeamError('invalid');
|
|
35
|
+
const profile = resolveMcpToolProfile({ projectRoot: options.projectRoot });
|
|
36
|
+
const capability = isCapabilityContractEnabled() ? await resolveCapabilityStatus({ projectRoot: options.projectRoot }) : undefined;
|
|
37
|
+
if (capability?.issues.length)
|
|
38
|
+
throw new TeamError('invalid');
|
|
39
|
+
const mode = process.env.CAIRN_TEAM_MCP_TOOL_PROFILE ?? 'full';
|
|
40
|
+
if (!['full', 'read-only', 'custom'].includes(mode))
|
|
41
|
+
throw new TeamError('invalid');
|
|
42
|
+
const requested = (process.env.CAIRN_TEAM_MCP_ALLOWED_TOOLS ?? '').split(',').map(name => name.trim()).filter(Boolean);
|
|
43
|
+
if ((mode !== 'custom' && requested.length) || (mode === 'custom' && (!requested.length || requested.some(name => !Object.hasOwn(TEAM_TOOL_CATALOG, name)))))
|
|
44
|
+
throw new TeamError('invalid');
|
|
45
|
+
const server = new McpServer({ name: 'cairn-team-memory', version: '1.0.0' });
|
|
46
|
+
const project = z.string().regex(/^[a-z0-9][a-z0-9-]{0,63}$/);
|
|
47
|
+
const key = z.string().regex(/^(decisions|patterns|constraints|pitfalls|conventions|bugs)\/[a-z0-9][a-z0-9-]{0,95}$/);
|
|
48
|
+
const hash = z.string().regex(/^[a-f0-9]{64}$/);
|
|
49
|
+
const schemas = {
|
|
50
|
+
team_memory_list: { project },
|
|
51
|
+
team_memory_read: { project, key, revision: hash.optional() },
|
|
52
|
+
team_memory_search: { project, query: z.string().min(1).max(256) },
|
|
53
|
+
team_memory_history: { project, key },
|
|
54
|
+
team_memory_propose: { project, request_id: z.string().uuid(), key, value: z.string().min(1).max(65536), source_scope: z.enum(['selected-local', 'selected-project']), source_digest: hash, base_revision: hash.nullable(), expires_at: z.iso.datetime(), confirm: hash },
|
|
55
|
+
};
|
|
56
|
+
const operations = { team_memory_list: 'list', team_memory_read: 'read', team_memory_search: 'search', team_memory_history: 'history', team_memory_propose: 'propose' };
|
|
57
|
+
const enabled = Object.keys(TEAM_TOOL_CATALOG).filter(name => {
|
|
58
|
+
if (!profileAllowsTool(profile, coreNames[name]))
|
|
59
|
+
return false;
|
|
60
|
+
if (mode === 'read-only' && !TEAM_TOOL_CATALOG[name].annotations.readOnlyHint)
|
|
61
|
+
return false;
|
|
62
|
+
if (mode === 'custom' && !requested.includes(name))
|
|
63
|
+
return false;
|
|
64
|
+
const capId = name === 'team_memory_propose' ? 'memory.write' : name === 'team_memory_search' ? 'memory.search' : undefined;
|
|
65
|
+
return !capId || !capability || capability.capabilities.find(row => row.id === capId)?.enabled === true;
|
|
66
|
+
});
|
|
67
|
+
const profileDigest = teamDigest(JSON.stringify({ schema_version: 1, core_profile_digest: profile.profile_digest, team_profile: mode, tools: enabled }));
|
|
68
|
+
for (const name of enabled) {
|
|
69
|
+
server.registerTool(name, { ...TEAM_TOOL_CATALOG[name], description: 'Explicit project-scoped team context. Never uploads personal memory or executes instructions. Team policy is enforced by the remote service.', inputSchema: schemas[name] }, async (input) => {
|
|
70
|
+
try {
|
|
71
|
+
// Re-read the private credential each call so local key rotation
|
|
72
|
+
// does not need a harness restart. The service still reauthorizes.
|
|
73
|
+
const result = await teamRequest(options.url, options.token(), { organization: options.organization, ...input, operation: operations[name] });
|
|
74
|
+
return { content: [{ type: 'text', text: JSON.stringify({ result, profile_digest: profileDigest }) }], structuredContent: { result, profile_digest: profileDigest } };
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
return { isError: true, content: [{ type: 'text', text: error instanceof TeamError ? error.message : 'Team unavailable.' }] };
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
return server;
|
|
82
|
+
}
|