@mokoconsulting/mokoabs-mcp 0.0.0-stage → 4.0.2
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/.editorconfig +41 -0
- package/.gitattributes +94 -0
- package/.gitmessage +9 -0
- package/.mokogit/ISSUE_TEMPLATE/adr.md +110 -0
- package/.mokogit/ISSUE_TEMPLATE/bug_report.md +48 -0
- package/.mokogit/ISSUE_TEMPLATE/config.yml +18 -0
- package/.mokogit/ISSUE_TEMPLATE/documentation.md +52 -0
- package/.mokogit/ISSUE_TEMPLATE/feature_request.md +51 -0
- package/.mokogit/ISSUE_TEMPLATE/mcp_api_integration.md +48 -0
- package/.mokogit/ISSUE_TEMPLATE/mcp_connection_issue.md +69 -0
- package/.mokogit/ISSUE_TEMPLATE/mcp_tool_request.md +50 -0
- package/.mokogit/ISSUE_TEMPLATE/question.md +82 -0
- package/.mokogit/ISSUE_TEMPLATE/rfc.md +126 -0
- package/.mokogit/ISSUE_TEMPLATE/security.md +51 -0
- package/.mokogit/ISSUE_TEMPLATE/version.md +24 -0
- package/.mokogit/actions/resolve-source-dir/action.yml +72 -0
- package/.mokogit/workflows/apidocs.yml +132 -0
- package/.mokogit/workflows/auto-assign.yml +76 -0
- package/.mokogit/workflows/auto-dev-issue.yml +207 -0
- package/.mokogit/workflows/auto-release.yml +1787 -0
- package/.mokogit/workflows/branch-cleanup.yml +63 -0
- package/.mokogit/workflows/cascade.yml +645 -0
- package/.mokogit/workflows/ci-issue-reporter.yml +100 -0
- package/.mokogit/workflows/cleanup.yml +112 -0
- package/.mokogit/workflows/makefile-lint.yml +240 -0
- package/.mokogit/workflows/npm-build-test.yml +507 -0
- package/.mokogit/workflows/npm-sdk-check.yml +110 -0
- package/.mokogit/workflows/npm-tool-inventory.yml +81 -0
- package/.mokogit/workflows/pr-branch-check.yml +92 -0
- package/.mokogit/workflows/pre-release.yml +1134 -0
- package/.mokogit/workflows/rc-revert.yml +75 -0
- package/AGENTS.md +48 -0
- package/CHANGELOG.md +37 -0
- package/CODE_OF_CONDUCT.md +70 -0
- package/CONTRIBUTING.md +164 -0
- package/LICENSE +696 -0
- package/Makefile +94 -0
- package/README.md +109 -2
- package/SECURITY.md +245 -0
- package/build/README.md +7 -0
- package/dist/README.md +7 -0
- package/dist/index.js +153 -0
- package/package.json +34 -5
- package/sample/config.example.json +15 -0
- package/source/client.ts +132 -0
- package/source/config.ts +139 -0
- package/source/credentialStore.ts +216 -0
- package/source/index.ts +235 -0
- package/source/tokenReader.ts +120 -0
- package/source/types.ts +63 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_comment": "No inline secrets. Lead every connection with the file-backed `tokenFile`. An inline `apiKey` is deprecated/legacy and is auto-migrated to ~/.ssh/tokens/<slug>.token.json (and this config rewritten) on first run.",
|
|
3
|
+
"defaultConnection": "production",
|
|
4
|
+
"connections": {
|
|
5
|
+
"production": {
|
|
6
|
+
"baseUrl": "https://abs.mokoconsulting.tech",
|
|
7
|
+
"tokenFile": "~/.ssh/tokens/abs.mokoconsulting.tech.token"
|
|
8
|
+
},
|
|
9
|
+
"legacy-inline": {
|
|
10
|
+
"_comment": "DEPRECATED: inline apiKey. Shown only to illustrate the legacy shape; it is auto-migrated to a token file on first run.",
|
|
11
|
+
"baseUrl": "https://abs.example.com",
|
|
12
|
+
"apiKey": "legacy-inline-api-key-auto-migrated-on-first-run"
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
}
|
package/source/client.ts
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
|
2
|
+
*
|
|
3
|
+
* This file is part of a Moko Consulting project.
|
|
4
|
+
*
|
|
5
|
+
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
6
|
+
*
|
|
7
|
+
* FILE INFORMATION
|
|
8
|
+
* DEFGROUP: mokoabs-mcp.Client
|
|
9
|
+
* INGROUP: mokoabs-mcp
|
|
10
|
+
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoabs-mcp
|
|
11
|
+
* PATH: /source/client.ts
|
|
12
|
+
* VERSION: 01.00.00
|
|
13
|
+
* BRIEF: HTTP client for Audiobookshelf API
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import * as https from 'node:https';
|
|
17
|
+
import * as http from 'node:http';
|
|
18
|
+
import type { ApiConnection, ApiResponse } from './types.js';
|
|
19
|
+
|
|
20
|
+
// ── Customize these ─────────────────────────────────────────────────────
|
|
21
|
+
// API path prefix appended to baseUrl (e.g. "/api/index.php", "/api/v1")
|
|
22
|
+
const API_PREFIX = '/api';
|
|
23
|
+
const TIMEOUT_MS = 30_000;
|
|
24
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
export class ApiClient {
|
|
27
|
+
private readonly base_url: string;
|
|
28
|
+
private readonly headers: Record<string, string>;
|
|
29
|
+
private readonly insecure: boolean;
|
|
30
|
+
|
|
31
|
+
constructor(conn: ApiConnection) {
|
|
32
|
+
this.base_url = conn.baseUrl.replace(/\/+$/, '') + API_PREFIX;
|
|
33
|
+
|
|
34
|
+
// ── Customize auth headers ──────────────────────────────────
|
|
35
|
+
// The bearer is resolved at config-load time (MokoCLI #1043) from the
|
|
36
|
+
// connection's apiKey/tokenFile; fall back to a raw apiKey for safety.
|
|
37
|
+
// Examples:
|
|
38
|
+
// Bearer token: { 'Authorization': `Bearer ${conn.bearer}` }
|
|
39
|
+
// API key header: { 'DOLAPIKEY': conn.bearer }
|
|
40
|
+
// Basic auth: { 'Authorization': `Basic ${btoa(user + ':' + pass)}` }
|
|
41
|
+
const bearer = conn.bearer ?? conn.apiKey ?? '';
|
|
42
|
+
this.headers = {
|
|
43
|
+
'Authorization': `Bearer ${bearer}`,
|
|
44
|
+
'Content-Type': 'application/json',
|
|
45
|
+
'Accept': 'application/json',
|
|
46
|
+
};
|
|
47
|
+
this.insecure = conn.insecure ?? false;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async get(endpoint: string, params?: Record<string, string>): Promise<ApiResponse> {
|
|
51
|
+
return this.request(this.buildUrl(endpoint, params), 'GET');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async post(endpoint: string, body?: unknown): Promise<ApiResponse> {
|
|
55
|
+
return this.request(this.buildUrl(endpoint), 'POST', body);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async put(endpoint: string, body: unknown): Promise<ApiResponse> {
|
|
59
|
+
return this.request(this.buildUrl(endpoint), 'PUT', body);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async patch(endpoint: string, body: unknown): Promise<ApiResponse> {
|
|
63
|
+
return this.request(this.buildUrl(endpoint), 'PATCH', body);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async delete(endpoint: string): Promise<ApiResponse> {
|
|
67
|
+
return this.request(this.buildUrl(endpoint), 'DELETE');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private buildUrl(endpoint: string, params?: Record<string, string>): string {
|
|
71
|
+
const path = endpoint.startsWith('/') ? endpoint : `/${endpoint}`;
|
|
72
|
+
const url = new URL(`${this.base_url}${path}`);
|
|
73
|
+
if (params) {
|
|
74
|
+
for (const [key, value] of Object.entries(params)) {
|
|
75
|
+
url.searchParams.set(key, value);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return url.toString();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private request(url: string, method: string, body?: unknown): Promise<ApiResponse> {
|
|
82
|
+
return new Promise((resolve, reject) => {
|
|
83
|
+
const parsed = new URL(url);
|
|
84
|
+
const is_https = parsed.protocol === 'https:';
|
|
85
|
+
const transport = is_https ? https : http;
|
|
86
|
+
|
|
87
|
+
const options: https.RequestOptions = {
|
|
88
|
+
hostname: parsed.hostname,
|
|
89
|
+
port: parsed.port || (is_https ? 443 : 80),
|
|
90
|
+
path: parsed.pathname + parsed.search,
|
|
91
|
+
method,
|
|
92
|
+
headers: { ...this.headers },
|
|
93
|
+
timeout: TIMEOUT_MS,
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
if (this.insecure && is_https) {
|
|
97
|
+
options.rejectUnauthorized = false;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const payload = body !== undefined ? JSON.stringify(body) : undefined;
|
|
101
|
+
if (payload) {
|
|
102
|
+
(options.headers as Record<string, string>)['Content-Length'] = Buffer.byteLength(payload).toString();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const req = transport.request(options, (res) => {
|
|
106
|
+
const chunks: Buffer[] = [];
|
|
107
|
+
res.on('data', (chunk: Buffer) => chunks.push(chunk));
|
|
108
|
+
res.on('end', () => {
|
|
109
|
+
const raw = Buffer.concat(chunks).toString('utf-8');
|
|
110
|
+
let data: unknown;
|
|
111
|
+
try {
|
|
112
|
+
data = JSON.parse(raw);
|
|
113
|
+
} catch {
|
|
114
|
+
data = raw;
|
|
115
|
+
}
|
|
116
|
+
resolve({ status: res.statusCode ?? 0, data });
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
req.on('error', (err) => reject(err));
|
|
121
|
+
req.on('timeout', () => {
|
|
122
|
+
req.destroy();
|
|
123
|
+
reject(new Error('Request timed out'));
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
if (payload) {
|
|
127
|
+
req.write(payload);
|
|
128
|
+
}
|
|
129
|
+
req.end();
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}
|
package/source/config.ts
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
|
2
|
+
*
|
|
3
|
+
* This file is part of a Moko Consulting project.
|
|
4
|
+
*
|
|
5
|
+
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
6
|
+
*
|
|
7
|
+
* FILE INFORMATION
|
|
8
|
+
* DEFGROUP: mokoabs-mcp.Config
|
|
9
|
+
* INGROUP: mokoabs-mcp
|
|
10
|
+
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoabs-mcp
|
|
11
|
+
* PATH: /source/config.ts
|
|
12
|
+
* VERSION: 01.00.00
|
|
13
|
+
* BRIEF: Configuration loader for Audiobookshelf MCP connections
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { readFile } from 'node:fs/promises';
|
|
17
|
+
import { resolve } from 'node:path';
|
|
18
|
+
import { homedir } from 'node:os';
|
|
19
|
+
import type { ApiConfig, ApiConnection } from './types.js';
|
|
20
|
+
import { resolveTokenValue, readTokenFile } from './tokenReader.js';
|
|
21
|
+
import { migrateInlineSecret, safeCredentialPath } from './credentialStore.js';
|
|
22
|
+
|
|
23
|
+
// ── Customize this ──────────────────────────────────────────────────────
|
|
24
|
+
// Change the filename to match your project (e.g. ".dolibarr-api-mcp.json")
|
|
25
|
+
const CONFIG_FILENAME = '.mcp-mokoabs.json';
|
|
26
|
+
// Change the env var name to match your project
|
|
27
|
+
const CONFIG_ENV_VAR = 'MOKOABS_CONFIG';
|
|
28
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
export async function loadConfig(): Promise<ApiConfig> {
|
|
31
|
+
const config_path = process.env[CONFIG_ENV_VAR]
|
|
32
|
+
? resolve(process.env[CONFIG_ENV_VAR]!)
|
|
33
|
+
: resolve(homedir(), CONFIG_FILENAME);
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
const raw = await readFile(config_path, 'utf-8');
|
|
37
|
+
const parsed = JSON.parse(raw) as Partial<ApiConfig>;
|
|
38
|
+
|
|
39
|
+
if (!parsed.connections || Object.keys(parsed.connections).length === 0) {
|
|
40
|
+
throw new Error('No connections defined in config');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Resolve each connection's credential into a bearer per MokoCLI #1043.
|
|
44
|
+
// A file-backed `tokenFile` takes precedence over an inline `apiKey`.
|
|
45
|
+
// Raw bearer strings pass through unchanged (back-compat); GitHub JSON
|
|
46
|
+
// blobs yield their token/access_token; Google creds are refused. Any
|
|
47
|
+
// inline `apiKey` literal is warned + auto-migrated to a token file and
|
|
48
|
+
// the on-disk config is rewritten (no-inline-secrets policy, Pass 2).
|
|
49
|
+
for (const [name, conn] of Object.entries(parsed.connections)) {
|
|
50
|
+
await resolveConnectionBearer(conn, name, config_path);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
connections: parsed.connections,
|
|
55
|
+
defaultConnection: parsed.defaultConnection ?? Object.keys(parsed.connections)[0],
|
|
56
|
+
};
|
|
57
|
+
} catch (err) {
|
|
58
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
59
|
+
throw new Error(
|
|
60
|
+
`Failed to load config from ${config_path}: ${message}\n` +
|
|
61
|
+
`Create ${config_path} — see config.example.json for format.`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Resolve a single connection's credential into `conn.bearer` (in place).
|
|
68
|
+
*
|
|
69
|
+
* Precedence: a file-backed `tokenFile` wins over an inline `apiKey`. The chosen
|
|
70
|
+
* credential runs through the MokoCLI #1043 ladder — raw strings pass through,
|
|
71
|
+
* GitHub JSON yields its token/access_token, Google creds are detected and
|
|
72
|
+
* refused (unusable bearer). Secret values are never logged.
|
|
73
|
+
*
|
|
74
|
+
* No-inline-secrets (Pass 2): a `tokenFile` from the HOME config is a TRUSTED
|
|
75
|
+
* source and is path-safety-checked (§1b) before being read. An inline `apiKey`
|
|
76
|
+
* literal is warned + auto-migrated to ~/.ssh/tokens/<slug>.token.json and the
|
|
77
|
+
* on-disk config rewritten; the current run still uses the resolved bearer even
|
|
78
|
+
* if migration falls back to warn-only.
|
|
79
|
+
*/
|
|
80
|
+
async function resolveConnectionBearer(conn: ApiConnection, name: string, configPath: string): Promise<void> {
|
|
81
|
+
let resolved;
|
|
82
|
+
|
|
83
|
+
if (conn.tokenFile) {
|
|
84
|
+
// §1b path safety — the config is a trusted source (HOME / $MOKOABS_CONFIG).
|
|
85
|
+
const safe = safeCredentialPath(conn.tokenFile, { trusted: true });
|
|
86
|
+
if (!safe) {
|
|
87
|
+
throw new Error(`Connection "${name}" tokenFile "${conn.tokenFile}" failed credential-file safety checks (see warning above).`);
|
|
88
|
+
}
|
|
89
|
+
resolved = await readTokenFile(safe, `connection "${name}".tokenFile`);
|
|
90
|
+
} else if (conn.apiKey !== undefined) {
|
|
91
|
+
// Resolve first (validates shape; raw passes through). Then, if the value
|
|
92
|
+
// is an inline literal secret, auto-migrate it out of the config.
|
|
93
|
+
resolved = resolveTokenValue(conn.apiKey, `connection "${name}".apiKey`);
|
|
94
|
+
|
|
95
|
+
const migrated = migrateInlineSecret({
|
|
96
|
+
configPath,
|
|
97
|
+
connectionName: name,
|
|
98
|
+
field: 'apiKey',
|
|
99
|
+
secret: conn.apiKey,
|
|
100
|
+
baseUrl: conn.baseUrl,
|
|
101
|
+
primary: true,
|
|
102
|
+
});
|
|
103
|
+
if (migrated) {
|
|
104
|
+
// Reflect the migration in the in-memory config for this run, and use
|
|
105
|
+
// the FILE as the authoritative secret going forward (§1.6). When an
|
|
106
|
+
// existing token file was reused, its contents may differ from the
|
|
107
|
+
// inline value we just resolved — re-resolve from the file so the
|
|
108
|
+
// current run matches what every future run will use.
|
|
109
|
+
conn.tokenFile = migrated.tokenFile;
|
|
110
|
+
delete conn.apiKey;
|
|
111
|
+
const safe = safeCredentialPath(migrated.tokenFile, { trusted: true });
|
|
112
|
+
if (safe) {
|
|
113
|
+
resolved = await readTokenFile(safe, `connection "${name}".tokenFile (migrated)`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
} else {
|
|
117
|
+
throw new Error(`Connection "${name}" has neither "apiKey" nor "tokenFile" — nothing to authenticate with.`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (resolved.needsExchange || !resolved.bearer) {
|
|
121
|
+
throw new Error(
|
|
122
|
+
`Connection "${name}" credential is not usable as a bearer token` +
|
|
123
|
+
(resolved.kind === 'google' ? ' (Google credentials require token exchange, which this server does not perform).' : '.'),
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
conn.bearer = resolved.bearer;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function getConnection(config: ApiConfig, name?: string): ApiConnection {
|
|
131
|
+
const key = name ?? config.defaultConnection;
|
|
132
|
+
const conn = config.connections[key];
|
|
133
|
+
if (!conn) {
|
|
134
|
+
throw new Error(
|
|
135
|
+
`Connection "${key}" not found. Available: ${Object.keys(config.connections).join(', ')}`,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
return conn;
|
|
139
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
|
2
|
+
*
|
|
3
|
+
* This file is part of a Moko Consulting project.
|
|
4
|
+
*
|
|
5
|
+
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
6
|
+
*
|
|
7
|
+
* FILE INFORMATION
|
|
8
|
+
* DEFGROUP: mokoabs-mcp.CredentialStore
|
|
9
|
+
* INGROUP: mokoabs-mcp
|
|
10
|
+
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoabs-mcp
|
|
11
|
+
* PATH: /source/credentialStore.ts
|
|
12
|
+
* VERSION: 01.00.00
|
|
13
|
+
* BRIEF: No-inline-secrets policy — auto-migration + credential-file path safety.
|
|
14
|
+
*
|
|
15
|
+
* Policy (MokoCLI #1043, Pass 2): no secure information may EVER be inline in the
|
|
16
|
+
* MCP settings file. For mokoabs-mcp the only secret field is a connection's
|
|
17
|
+
* `apiKey` (the Audiobookshelf bearer). When an inline literal is found in the
|
|
18
|
+
* on-disk config we warn + auto-migrate it to ~/.ssh/tokens/<slug>.token.json and
|
|
19
|
+
* rewrite the config to reference it via `tokenFile`. Secret values are NEVER
|
|
20
|
+
* printed; warnings go to stderr only (stdout is the MCP stdio channel).
|
|
21
|
+
*/
|
|
22
|
+
import { constants as FS } from 'node:fs';
|
|
23
|
+
import {
|
|
24
|
+
accessSync,
|
|
25
|
+
chmodSync,
|
|
26
|
+
copyFileSync,
|
|
27
|
+
existsSync,
|
|
28
|
+
mkdirSync,
|
|
29
|
+
readFileSync,
|
|
30
|
+
renameSync,
|
|
31
|
+
statSync,
|
|
32
|
+
writeFileSync,
|
|
33
|
+
} from 'node:fs';
|
|
34
|
+
import { homedir } from 'node:os';
|
|
35
|
+
import { dirname, join, relative, resolve } from 'node:path';
|
|
36
|
+
import { expandHome } from './tokenReader.js';
|
|
37
|
+
|
|
38
|
+
/** stderr ONLY — never stdout; never prints secret values. */
|
|
39
|
+
function warn(msg: string): void {
|
|
40
|
+
process.stderr.write(`[credential] WARNING: ${msg}\n`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Max size for a credential file we are willing to read (64 KiB). */
|
|
44
|
+
const MAX_CRED_FILE_BYTES = 64 * 1024;
|
|
45
|
+
|
|
46
|
+
/** Sanitize an arbitrary label into a safe filename fragment ([A-Za-z0-9._-]). */
|
|
47
|
+
function sanitizeSlug(s: string): string {
|
|
48
|
+
return (s || '').replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'connection';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Derive the host portion of a baseUrl, or '' if unparseable. */
|
|
52
|
+
function hostOf(baseUrl: string | undefined): string {
|
|
53
|
+
if (!baseUrl) return '';
|
|
54
|
+
try {
|
|
55
|
+
return new URL(baseUrl).host;
|
|
56
|
+
} catch {
|
|
57
|
+
return '';
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Validate a credential-file reference before it is read (section 1b).
|
|
63
|
+
* Returns the resolved absolute path when safe, or null (after a stderr warning)
|
|
64
|
+
* when it must be skipped. `trusted` MUST be false for any reference that came
|
|
65
|
+
* from an untrusted source (e.g. a cwd .env) — those are never honored as paths.
|
|
66
|
+
*/
|
|
67
|
+
export function safeCredentialPath(ref: string, opts: { trusted: boolean }): string | null {
|
|
68
|
+
if (!opts.trusted) {
|
|
69
|
+
warn(`credential-file reference "${ref}" came from an untrusted source (cwd .env) — refusing to open it; provide the secret inline or via the HOME config.`);
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
const abs = expandHome(ref);
|
|
73
|
+
const home = resolve(homedir());
|
|
74
|
+
const rel = relative(home, abs);
|
|
75
|
+
// Must resolve to a path strictly under $HOME (reject the home dir itself,
|
|
76
|
+
// any absolute path outside home, and any ".." escape).
|
|
77
|
+
if (rel === '' || rel.startsWith('..') || rel.split(/[\\/]/).includes('..')) {
|
|
78
|
+
warn(`credential file "${abs}" is outside $HOME (or escapes it via "..") — refusing to open it.`);
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
let st;
|
|
82
|
+
try {
|
|
83
|
+
st = statSync(abs);
|
|
84
|
+
} catch (err) {
|
|
85
|
+
warn(`credential file "${abs}" could not be stat'd (${err instanceof Error ? err.message : String(err)}) — skipping.`);
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
if (!st.isFile()) {
|
|
89
|
+
warn(`credential file "${abs}" is not a regular file — skipping.`);
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
// Owner-read-only: no group/world bits. (On Windows these bits are not
|
|
93
|
+
// meaningfully enforced, so skip the perm check there to avoid false rejects.)
|
|
94
|
+
if (process.platform !== 'win32' && (st.mode & 0o077) !== 0) {
|
|
95
|
+
warn(`credential file "${abs}" is group/world accessible (mode ${(st.mode & 0o777).toString(8)}) — refusing to open it; chmod 600.`);
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
if (st.size > MAX_CRED_FILE_BYTES) {
|
|
99
|
+
warn(`credential file "${abs}" is larger than ${MAX_CRED_FILE_BYTES} bytes — refusing to open it (not a credential file).`);
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
return abs;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface MigrationResult {
|
|
106
|
+
/** Absolute path of the file now holding the secret (may be a pre-existing one). */
|
|
107
|
+
tokenFile: string;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Auto-migrate an inline secret out of the on-disk config into
|
|
112
|
+
* ~/.ssh/tokens/<slug>.token.json and rewrite the config to reference it.
|
|
113
|
+
* Returns the token-file path on success, or null (warn-only) if migration
|
|
114
|
+
* could not be completed — the caller then keeps using the inline value.
|
|
115
|
+
*
|
|
116
|
+
* NEVER throws for an expected failure (read-only FS, perms): warns and returns
|
|
117
|
+
* null so the server keeps running with the inline value.
|
|
118
|
+
*/
|
|
119
|
+
export function migrateInlineSecret(args: {
|
|
120
|
+
configPath: string;
|
|
121
|
+
connectionName: string;
|
|
122
|
+
field: string;
|
|
123
|
+
secret: string;
|
|
124
|
+
baseUrl?: string;
|
|
125
|
+
/** Whether `field` is the primary bearer field (no suffix) or a secondary one. */
|
|
126
|
+
primary: boolean;
|
|
127
|
+
}): MigrationResult | null {
|
|
128
|
+
const { configPath, connectionName, field, secret, baseUrl, primary } = args;
|
|
129
|
+
try {
|
|
130
|
+
// Only migrate the real on-disk config (it must exist and be writable).
|
|
131
|
+
if (!existsSync(configPath)) return null;
|
|
132
|
+
try {
|
|
133
|
+
accessSync(configPath, FS.W_OK);
|
|
134
|
+
} catch {
|
|
135
|
+
warn(`inline secret ${connectionName}.${field} detected but config "${configPath}" is not writable — keeping inline (warn-only).`);
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const tokensDir = join(resolve(homedir()), '.ssh', 'tokens');
|
|
140
|
+
mkdirSync(tokensDir, { recursive: true, mode: 0o700 });
|
|
141
|
+
|
|
142
|
+
const base = sanitizeSlug(hostOf(baseUrl) || connectionName);
|
|
143
|
+
const suffix = primary ? '' : `-${sanitizeSlug(field)}`;
|
|
144
|
+
const target = join(tokensDir, `${base}${suffix}.token.json`);
|
|
145
|
+
|
|
146
|
+
let reusedExisting = false;
|
|
147
|
+
if (existsSync(target)) {
|
|
148
|
+
reusedExisting = true;
|
|
149
|
+
} else {
|
|
150
|
+
const payload = JSON.stringify(
|
|
151
|
+
{
|
|
152
|
+
token: secret,
|
|
153
|
+
host: hostOf(baseUrl) || undefined,
|
|
154
|
+
note: 'auto-migrated from inline mokoabs MCP config (no-inline-secrets policy, MokoCLI #1043)',
|
|
155
|
+
migrated_at: new Date().toISOString(),
|
|
156
|
+
},
|
|
157
|
+
null,
|
|
158
|
+
2,
|
|
159
|
+
);
|
|
160
|
+
writeFileSync(target, payload + '\n', { mode: 0o600, flag: 'wx' });
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Back up the config ONCE (preserving the old inline secret), then rewrite.
|
|
164
|
+
const backup = `${configPath}.bak`;
|
|
165
|
+
if (!existsSync(backup)) {
|
|
166
|
+
try {
|
|
167
|
+
copyFileSync(configPath, backup, FS.COPYFILE_EXCL);
|
|
168
|
+
} catch {
|
|
169
|
+
// Non-fatal: continue with the rewrite even if the backup races.
|
|
170
|
+
}
|
|
171
|
+
try {
|
|
172
|
+
// Harden the backup perms; it still holds the old secret.
|
|
173
|
+
chmodSync(backup, 0o600);
|
|
174
|
+
} catch { /* best-effort */ }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
rewriteConfigTokenFile(configPath, connectionName, field, target);
|
|
178
|
+
|
|
179
|
+
if (reusedExisting) {
|
|
180
|
+
warn(`inline secret ${connectionName}.${field} migrated: a token file already existed at ${target}; reusing it (did NOT overwrite). Config rewritten to reference it. Backup at ${backup} STILL CONTAINS the old inline secret — delete it after verifying.`);
|
|
181
|
+
} else {
|
|
182
|
+
warn(`inline secret ${connectionName}.${field} migrated to ${target} (mode 0600) and config rewritten to reference it via tokenFile. Backup at ${backup} STILL CONTAINS the old inline secret — delete ${backup} after verifying the migration.`);
|
|
183
|
+
}
|
|
184
|
+
return { tokenFile: target };
|
|
185
|
+
} catch (err) {
|
|
186
|
+
warn(`auto-migration of ${connectionName}.${field} failed (${err instanceof Error ? err.message : String(err)}) — keeping inline value (warn-only).`);
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Atomically rewrite the config: set connection[field→tokenFile] = target and
|
|
193
|
+
* delete the inline `field`. Preserves all other fields; writes temp + rename.
|
|
194
|
+
*/
|
|
195
|
+
function rewriteConfigTokenFile(configPath: string, connectionName: string, field: string, target: string): void {
|
|
196
|
+
const raw = readFileSync(configPath, 'utf-8');
|
|
197
|
+
const parsed = JSON.parse(raw) as {
|
|
198
|
+
connections?: Record<string, Record<string, unknown>>;
|
|
199
|
+
[k: string]: unknown;
|
|
200
|
+
};
|
|
201
|
+
const conn = parsed.connections?.[connectionName];
|
|
202
|
+
if (!conn) throw new Error(`connection "${connectionName}" vanished from config`);
|
|
203
|
+
|
|
204
|
+
// Map the inline secret field to its file-ref field. For mokoabs the only
|
|
205
|
+
// secret is apiKey → tokenFile; a generic *_password would map to *File.
|
|
206
|
+
const fileField = field === 'apiKey' ? 'tokenFile' : `${field}File`;
|
|
207
|
+
conn[fileField] = `~/${relative(resolve(homedir()), target).split(/[\\/]/).join('/')}`;
|
|
208
|
+
delete conn[field];
|
|
209
|
+
|
|
210
|
+
const tmp = `${configPath}.tmp-${process.pid}-${Date.now()}`;
|
|
211
|
+
writeFileSync(tmp, JSON.stringify(parsed, null, '\t') + '\n', { mode: 0o600 });
|
|
212
|
+
renameSync(tmp, configPath);
|
|
213
|
+
try {
|
|
214
|
+
chmodSync(configPath, 0o600);
|
|
215
|
+
} catch { /* best-effort on platforms without POSIX perms */ }
|
|
216
|
+
}
|