@autokap/core 0.1.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 +45 -0
- package/dist/api-client.d.ts +53 -0
- package/dist/api-client.js +211 -0
- package/dist/config.d.ts +37 -0
- package/dist/config.js +404 -0
- package/dist/endpoint-helpers.d.ts +12 -0
- package/dist/endpoint-helpers.js +33 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +6 -0
- package/dist/logger.d.ts +22 -0
- package/dist/logger.js +91 -0
- package/dist/types.d.ts +19 -0
- package/dist/types.js +2 -0
- package/package.json +61 -0
package/README.md
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# @autokap/core
|
|
2
|
+
|
|
3
|
+
Shared core library consumed by the bundled `autokap` CLI binary and the
|
|
4
|
+
`@autokap/mcp` server. **You probably do not need to install this package
|
|
5
|
+
directly** — install `@autokap/mcp` (for IDE agents) or `autokap` (for
|
|
6
|
+
CI / Cloud Run) and let them pull `@autokap/core` in transitively.
|
|
7
|
+
|
|
8
|
+
The package exists to keep request shaping, schema validation, secret
|
|
9
|
+
redaction, and the SSRF-aware config layer in a single source of truth
|
|
10
|
+
across the two consumers.
|
|
11
|
+
|
|
12
|
+
## Entry points
|
|
13
|
+
|
|
14
|
+
```ts
|
|
15
|
+
import { /* ... */ } from "@autokap/core"; // re-exports everything below
|
|
16
|
+
import { /* ... */ } from "@autokap/core/config"; // ~/.autokap/config.json, SSRF guards
|
|
17
|
+
import { /* ... */ } from "@autokap/core/api-client"; // apiCall(), validateApiKey(), ApiClientError
|
|
18
|
+
import { /* ... */ } from "@autokap/core/endpoint-helpers"; // buildEndpointAssetUrl, toCsv
|
|
19
|
+
import { /* ... */ } from "@autokap/core/types"; // ValidateResponse, AutokapConfig, CheckResult
|
|
20
|
+
import { /* ... */ } from "@autokap/core/logger"; // createStderrLogger, redactString, redactValue
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## What's in here
|
|
24
|
+
|
|
25
|
+
- **Config** — atomic-write `~/.autokap/config.json` with `chmod 0600` on
|
|
26
|
+
POSIX. The same file is read by the CLI and the MCP server so users
|
|
27
|
+
authenticate once.
|
|
28
|
+
- **SSRF / origin guard** (`validatePublicHttpUrl`) — rejects non-http(s),
|
|
29
|
+
loopback, private IPv4 (including obfuscated forms — `0177.0.0.1`,
|
|
30
|
+
`2130706433`, hex), `.local` / `.internal`, IPv6 ULA / link-local /
|
|
31
|
+
IPv4-mapped / 6to4 / NAT64.
|
|
32
|
+
- **API client** — typed `apiCall<T>()` wrapping `fetch` with bearer auth,
|
|
33
|
+
retry on 429 / 5xx (idempotent methods only), `Retry-After` honoring,
|
|
34
|
+
error envelope unpacking, and uniform secret redaction in error bodies.
|
|
35
|
+
- **Logger** — stderr-only logger (stdout is reserved for the MCP
|
|
36
|
+
transport) plus `redactString` / `redactValue` helpers that scrub
|
|
37
|
+
obvious tokens (`ak_cli_…`, `ak_run_…`, bearer headers) from any string
|
|
38
|
+
before it lands in an error message or log line.
|
|
39
|
+
|
|
40
|
+
## Versioning
|
|
41
|
+
|
|
42
|
+
`@autokap/core` is pinned **exactly** by `@autokap/mcp` and `autokap`.
|
|
43
|
+
When you bump it, bump the consumers' manifests in the same commit so
|
|
44
|
+
the dependency graph stays internally consistent. See the release
|
|
45
|
+
sequence in the root `CHANGELOG.md`.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { ValidateResponse } from './types.js';
|
|
2
|
+
export interface ValidateOptions {
|
|
3
|
+
apiKey: string;
|
|
4
|
+
apiBaseUrl: string;
|
|
5
|
+
signal?: AbortSignal;
|
|
6
|
+
timeoutMs?: number;
|
|
7
|
+
}
|
|
8
|
+
export declare function validateApiKey(options: ValidateOptions): Promise<ValidateResponse>;
|
|
9
|
+
export declare class ApiClientError extends Error {
|
|
10
|
+
readonly status: number;
|
|
11
|
+
readonly code?: string;
|
|
12
|
+
readonly params?: Record<string, unknown>;
|
|
13
|
+
readonly responseBody?: unknown;
|
|
14
|
+
constructor(message: string, status: number, options?: {
|
|
15
|
+
code?: string;
|
|
16
|
+
params?: Record<string, unknown>;
|
|
17
|
+
responseBody?: unknown;
|
|
18
|
+
cause?: unknown;
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
export type HttpMethod = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
|
|
22
|
+
export interface ApiCallContext {
|
|
23
|
+
apiKey: string;
|
|
24
|
+
apiBaseUrl: string;
|
|
25
|
+
}
|
|
26
|
+
export interface ApiCallOptions<TBody = unknown> {
|
|
27
|
+
method?: HttpMethod;
|
|
28
|
+
body?: TBody;
|
|
29
|
+
searchParams?: Record<string, string | number | boolean | undefined | null>;
|
|
30
|
+
headers?: Record<string, string>;
|
|
31
|
+
signal?: AbortSignal;
|
|
32
|
+
timeoutMs?: number;
|
|
33
|
+
/** When true, returns the raw response text instead of parsing JSON. */
|
|
34
|
+
asText?: boolean;
|
|
35
|
+
/**
|
|
36
|
+
* Number of retry attempts after a transient failure (5xx, 429, or network
|
|
37
|
+
* error). Only applied to idempotent methods (GET, DELETE) by default; pass
|
|
38
|
+
* `idempotent: true` to force retries on POST/PATCH/PUT.
|
|
39
|
+
*/
|
|
40
|
+
retries?: number;
|
|
41
|
+
idempotent?: boolean;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Generic AutoKap API helper used by every MCP tool. Handles auth, URL
|
|
45
|
+
* construction, error envelope unpacking (`{ error, code, params }`), retries
|
|
46
|
+
* on transient failures, and the 204 / empty-body cases.
|
|
47
|
+
*
|
|
48
|
+
* Non-JSON 200 responses are surfaced as ApiClientError unless `asText: true`
|
|
49
|
+
* — we never silently return text-as-T because that hides server contract
|
|
50
|
+
* drift from typed callers.
|
|
51
|
+
*/
|
|
52
|
+
export declare function apiCall<TResult = unknown, TBody = unknown>(context: ApiCallContext, path: string, options?: ApiCallOptions<TBody>): Promise<TResult>;
|
|
53
|
+
export declare function buildApiUrl(apiBaseUrl: string, path: string, searchParams?: Record<string, string | number | boolean | undefined | null>): string;
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { validateServerOrigin } from './config.js';
|
|
2
|
+
import { redactString, redactValue } from './logger.js';
|
|
3
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
|
|
4
|
+
const DEFAULT_RETRY_ATTEMPTS = 1;
|
|
5
|
+
export async function validateApiKey(options) {
|
|
6
|
+
const { apiKey, apiBaseUrl, signal, timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS } = options;
|
|
7
|
+
validateServerOrigin(apiBaseUrl);
|
|
8
|
+
const url = `${apiBaseUrl.replace(/\/+$/, '')}/api/cli/validate`;
|
|
9
|
+
let response;
|
|
10
|
+
try {
|
|
11
|
+
response = await fetch(url, {
|
|
12
|
+
method: 'GET',
|
|
13
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
14
|
+
signal: signal ?? AbortSignal.timeout(timeoutMs),
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
catch (err) {
|
|
18
|
+
throw wrapNetworkError(err, 'GET', '/api/cli/validate');
|
|
19
|
+
}
|
|
20
|
+
if (response.status === 401 || response.status === 403) {
|
|
21
|
+
return { valid: false };
|
|
22
|
+
}
|
|
23
|
+
if (!response.ok) {
|
|
24
|
+
throw new ApiClientError(`Validation endpoint returned HTTP ${response.status}`, response.status);
|
|
25
|
+
}
|
|
26
|
+
const data = (await response.json());
|
|
27
|
+
return data;
|
|
28
|
+
}
|
|
29
|
+
export class ApiClientError extends Error {
|
|
30
|
+
status;
|
|
31
|
+
code;
|
|
32
|
+
params;
|
|
33
|
+
responseBody;
|
|
34
|
+
constructor(message, status, options = {}) {
|
|
35
|
+
super(message, { cause: options.cause });
|
|
36
|
+
this.name = 'ApiClientError';
|
|
37
|
+
this.status = status;
|
|
38
|
+
this.code = options.code;
|
|
39
|
+
this.params = options.params;
|
|
40
|
+
this.responseBody = options.responseBody;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const IDEMPOTENT_METHODS = new Set(['GET', 'DELETE']);
|
|
44
|
+
/**
|
|
45
|
+
* Generic AutoKap API helper used by every MCP tool. Handles auth, URL
|
|
46
|
+
* construction, error envelope unpacking (`{ error, code, params }`), retries
|
|
47
|
+
* on transient failures, and the 204 / empty-body cases.
|
|
48
|
+
*
|
|
49
|
+
* Non-JSON 200 responses are surfaced as ApiClientError unless `asText: true`
|
|
50
|
+
* — we never silently return text-as-T because that hides server contract
|
|
51
|
+
* drift from typed callers.
|
|
52
|
+
*/
|
|
53
|
+
export async function apiCall(context, path, options = {}) {
|
|
54
|
+
const { method = 'GET', body, searchParams, headers, signal, timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS, asText = false, retries = DEFAULT_RETRY_ATTEMPTS, idempotent, } = options;
|
|
55
|
+
validateServerOrigin(context.apiBaseUrl);
|
|
56
|
+
const url = buildApiUrl(context.apiBaseUrl, path, searchParams);
|
|
57
|
+
const finalHeaders = {
|
|
58
|
+
Authorization: `Bearer ${context.apiKey}`,
|
|
59
|
+
...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
|
|
60
|
+
...(headers ?? {}),
|
|
61
|
+
};
|
|
62
|
+
const maxAttempts = Math.max(1, (idempotent ?? IDEMPOTENT_METHODS.has(method)) ? retries + 1 : 1);
|
|
63
|
+
let lastError;
|
|
64
|
+
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
|
65
|
+
let response;
|
|
66
|
+
try {
|
|
67
|
+
response = await fetch(url, {
|
|
68
|
+
method,
|
|
69
|
+
headers: finalHeaders,
|
|
70
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
71
|
+
signal: signal ?? AbortSignal.timeout(timeoutMs),
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
lastError = wrapNetworkError(err, method, path);
|
|
76
|
+
if (attempt < maxAttempts - 1) {
|
|
77
|
+
await sleep(backoffMs(attempt));
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
throw lastError;
|
|
81
|
+
}
|
|
82
|
+
if (response.status === 204) {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
if (!response.ok) {
|
|
86
|
+
const apiError = await buildApiClientError(response, method, path);
|
|
87
|
+
// Retry on 429 / 5xx if attempts remain and the method is idempotent.
|
|
88
|
+
const transient = response.status === 429 || response.status >= 500;
|
|
89
|
+
if (transient && attempt < maxAttempts - 1) {
|
|
90
|
+
lastError = apiError;
|
|
91
|
+
await sleep(retryAfterMs(response) ?? backoffMs(attempt));
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
throw apiError;
|
|
95
|
+
}
|
|
96
|
+
if (asText) {
|
|
97
|
+
return (await response.text());
|
|
98
|
+
}
|
|
99
|
+
const contentLength = response.headers.get('content-length');
|
|
100
|
+
if (contentLength === '0') {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
const contentType = response.headers.get('content-type') ?? '';
|
|
104
|
+
if (!contentType.toLowerCase().includes('json')) {
|
|
105
|
+
const text = await response.text();
|
|
106
|
+
if (text.trim().length === 0) {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
throw new ApiClientError(`Expected JSON response from ${method} ${path}, got content-type "${contentType || '(none)'}"`, response.status, { responseBody: text.slice(0, 200) });
|
|
110
|
+
}
|
|
111
|
+
return (await response.json());
|
|
112
|
+
}
|
|
113
|
+
// Unreachable — the loop either returns or throws — but TS doesn't know that.
|
|
114
|
+
throw lastError ?? new ApiClientError('apiCall exhausted retries', 0);
|
|
115
|
+
}
|
|
116
|
+
async function buildApiClientError(response, method, path) {
|
|
117
|
+
let errorBody = undefined;
|
|
118
|
+
let errorMessage = `Request ${method} ${path} failed with HTTP ${response.status}`;
|
|
119
|
+
let errorCode;
|
|
120
|
+
let errorParams;
|
|
121
|
+
try {
|
|
122
|
+
const text = await response.text();
|
|
123
|
+
if (text.trim().length > 0) {
|
|
124
|
+
try {
|
|
125
|
+
errorBody = JSON.parse(text);
|
|
126
|
+
if (errorBody && typeof errorBody === 'object') {
|
|
127
|
+
const envelope = errorBody;
|
|
128
|
+
if (typeof envelope.error === 'string' && envelope.error.length > 0) {
|
|
129
|
+
errorMessage = envelope.error;
|
|
130
|
+
}
|
|
131
|
+
if (typeof envelope.code === 'string') {
|
|
132
|
+
errorCode = envelope.code;
|
|
133
|
+
}
|
|
134
|
+
if (envelope.params && typeof envelope.params === 'object') {
|
|
135
|
+
errorParams = envelope.params;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
errorBody = text;
|
|
141
|
+
errorMessage = `${errorMessage}: ${text.slice(0, 200)}`;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
// ignore body read failures
|
|
147
|
+
}
|
|
148
|
+
// Always redact before constructing the error envelope. The body and the
|
|
149
|
+
// params/message frequently land in MCP tool error responses and IDE log
|
|
150
|
+
// panes; a server response that echoes the bearer or includes the API key
|
|
151
|
+
// in a stack trace would otherwise leak straight to the agent / user.
|
|
152
|
+
return new ApiClientError(redactString(errorMessage), response.status, {
|
|
153
|
+
code: errorCode,
|
|
154
|
+
params: errorParams ? redactValue(errorParams) : undefined,
|
|
155
|
+
responseBody: errorBody !== undefined ? redactValue(errorBody) : undefined,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
function wrapNetworkError(err, method, path) {
|
|
159
|
+
if (err instanceof ApiClientError)
|
|
160
|
+
return err;
|
|
161
|
+
const cause = err?.cause;
|
|
162
|
+
const causeCode = cause && typeof cause === 'object' && 'code' in cause ? cause.code : undefined;
|
|
163
|
+
const rawDetail = causeCode ? String(causeCode) : err.message ?? 'unknown network failure';
|
|
164
|
+
// Run the detail through the redactor before concatenating. Node fetch
|
|
165
|
+
// errors usually carry just an ENOTFOUND / ECONNREFUSED code, but a
|
|
166
|
+
// custom fetch polyfill (or a user-supplied URL containing inline
|
|
167
|
+
// credentials like `https://u:p@host/`) can echo the bearer or the
|
|
168
|
+
// basic-auth secret into the error message. Without redaction, those
|
|
169
|
+
// strings would land in the MCP tool error envelope the agent sees.
|
|
170
|
+
const detail = redactString(rawDetail);
|
|
171
|
+
return new ApiClientError(`Network failure on ${method} ${path}: ${detail}`, 0, { code: 'network_error', cause: err });
|
|
172
|
+
}
|
|
173
|
+
function sleep(ms) {
|
|
174
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
175
|
+
}
|
|
176
|
+
function backoffMs(attempt) {
|
|
177
|
+
// 250ms * 2^attempt + 0–250ms jitter — keeps the second try inside the 1s
|
|
178
|
+
// ballpark and the third inside ~2s.
|
|
179
|
+
return 250 * 2 ** attempt + Math.floor(Math.random() * 250);
|
|
180
|
+
}
|
|
181
|
+
function retryAfterMs(response) {
|
|
182
|
+
const header = response.headers.get('retry-after');
|
|
183
|
+
if (!header)
|
|
184
|
+
return null;
|
|
185
|
+
const asInt = Number.parseInt(header, 10);
|
|
186
|
+
if (Number.isFinite(asInt) && asInt >= 0) {
|
|
187
|
+
return Math.min(asInt * 1000, 30_000);
|
|
188
|
+
}
|
|
189
|
+
const asDate = Date.parse(header);
|
|
190
|
+
if (Number.isFinite(asDate)) {
|
|
191
|
+
return Math.max(0, Math.min(asDate - Date.now(), 30_000));
|
|
192
|
+
}
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
export function buildApiUrl(apiBaseUrl, path, searchParams) {
|
|
196
|
+
const trimmedBase = apiBaseUrl.replace(/\/+$/, '');
|
|
197
|
+
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
|
198
|
+
const url = new URL(`${trimmedBase}${normalizedPath}`);
|
|
199
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
200
|
+
throw new Error(`Refusing to build API URL with unsupported scheme "${url.protocol}"; only http(s) are allowed.`);
|
|
201
|
+
}
|
|
202
|
+
if (searchParams) {
|
|
203
|
+
for (const [key, value] of Object.entries(searchParams)) {
|
|
204
|
+
if (value === undefined || value === null)
|
|
205
|
+
continue;
|
|
206
|
+
url.searchParams.set(key, String(value));
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return url.toString();
|
|
210
|
+
}
|
|
211
|
+
//# sourceMappingURL=api-client.js.map
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { AutokapConfig } from './types.js';
|
|
2
|
+
declare const DEFAULT_API_BASE_URL = "https://autokap.app";
|
|
3
|
+
declare const DEFAULT_WS_URL = "wss://autokap.app/ws";
|
|
4
|
+
declare const LOCAL_API_BASE_URL = "http://localhost:3000";
|
|
5
|
+
declare const API_KEY_ENV_VAR = "AUTOKAP_API_KEY";
|
|
6
|
+
declare const RUN_TOKEN_ENV_VAR = "AUTOKAP_RUN_TOKEN";
|
|
7
|
+
declare const API_BASE_URL_ENV_VAR = "AUTOKAP_API_BASE_URL";
|
|
8
|
+
declare const WS_URL_ENV_VAR = "AUTOKAP_WS_URL";
|
|
9
|
+
declare const ALLOW_UNSAFE_SERVER_ORIGIN_ENV_VAR = "AUTOKAP_ALLOW_UNSAFE_SERVER_ORIGIN";
|
|
10
|
+
export declare function getConfigDir(): string;
|
|
11
|
+
export declare function getConfigPath(): string;
|
|
12
|
+
export declare function getDefaultApiBaseUrl(): string;
|
|
13
|
+
export declare function getDefaultWsUrl(apiBaseUrl?: string): string;
|
|
14
|
+
export declare function readConfig(): Promise<AutokapConfig | null>;
|
|
15
|
+
export declare function writeConfig(config: AutokapConfig): Promise<void>;
|
|
16
|
+
export declare function deleteConfig(): Promise<void>;
|
|
17
|
+
declare function assertAllowedApiOrigin(candidateUrl: string, baselineUrl: string, envVar?: string): void;
|
|
18
|
+
/**
|
|
19
|
+
* SSRF guard for any caller-supplied URL that the AutoKap stack will
|
|
20
|
+
* eventually fetch (MCP tool inputs — apiBaseUrl, proxyUrl, webhookUrl,
|
|
21
|
+
* loginUrl, project baseUrl, etc.).
|
|
22
|
+
*
|
|
23
|
+
* Rejects:
|
|
24
|
+
* - non-http(s) schemes (file://, javascript:, gopher://, data:, ...)
|
|
25
|
+
* - localhost / 127.0.0.0/8 / private IPv4 / link-local / IPv6 loopback
|
|
26
|
+
* - hostnames ending in `.local`, `.internal`
|
|
27
|
+
*
|
|
28
|
+
* When `AUTOKAP_ALLOW_UNSAFE_SERVER_ORIGIN=1`, the private-host check is
|
|
29
|
+
* relaxed for dev workflows. The scheme check is NEVER relaxed.
|
|
30
|
+
*
|
|
31
|
+
* `validatePublicHttpUrl` is the public alias used by MCP tools that accept
|
|
32
|
+
* user-provided URLs; `validateServerOrigin` is the alias used by core API
|
|
33
|
+
* client paths.
|
|
34
|
+
*/
|
|
35
|
+
export declare function validateServerOrigin(candidateUrl: string): void;
|
|
36
|
+
export declare function validatePublicHttpUrl(candidateUrl: string, fieldLabel?: string): void;
|
|
37
|
+
export { DEFAULT_API_BASE_URL, DEFAULT_WS_URL, LOCAL_API_BASE_URL, API_KEY_ENV_VAR, RUN_TOKEN_ENV_VAR, API_BASE_URL_ENV_VAR, WS_URL_ENV_VAR, ALLOW_UNSAFE_SERVER_ORIGIN_ENV_VAR, assertAllowedApiOrigin, };
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
const DEFAULT_API_BASE_URL = 'https://autokap.app';
|
|
6
|
+
const DEFAULT_WS_URL = 'wss://autokap.app/ws';
|
|
7
|
+
const LOCAL_API_BASE_URL = 'http://localhost:3000';
|
|
8
|
+
const API_KEY_ENV_VAR = 'AUTOKAP_API_KEY';
|
|
9
|
+
const RUN_TOKEN_ENV_VAR = 'AUTOKAP_RUN_TOKEN';
|
|
10
|
+
const API_BASE_URL_ENV_VAR = 'AUTOKAP_API_BASE_URL';
|
|
11
|
+
const WS_URL_ENV_VAR = 'AUTOKAP_WS_URL';
|
|
12
|
+
const ALLOW_UNSAFE_SERVER_ORIGIN_ENV_VAR = 'AUTOKAP_ALLOW_UNSAFE_SERVER_ORIGIN';
|
|
13
|
+
export function getConfigDir() {
|
|
14
|
+
return path.join(os.homedir(), '.autokap');
|
|
15
|
+
}
|
|
16
|
+
export function getConfigPath() {
|
|
17
|
+
return path.join(getConfigDir(), 'config.json');
|
|
18
|
+
}
|
|
19
|
+
export function getDefaultApiBaseUrl() {
|
|
20
|
+
return normalizeUrl(process.env[API_BASE_URL_ENV_VAR]) ?? DEFAULT_API_BASE_URL;
|
|
21
|
+
}
|
|
22
|
+
export function getDefaultWsUrl(apiBaseUrl = getDefaultApiBaseUrl()) {
|
|
23
|
+
return normalizeUrl(process.env[WS_URL_ENV_VAR]) ?? deriveWsUrl(apiBaseUrl);
|
|
24
|
+
}
|
|
25
|
+
export async function readConfig() {
|
|
26
|
+
const envRunToken = normalizeApiKey(process.env[RUN_TOKEN_ENV_VAR]);
|
|
27
|
+
if (envRunToken) {
|
|
28
|
+
const apiBaseUrl = getDefaultApiBaseUrl();
|
|
29
|
+
const wsUrl = getDefaultWsUrl(apiBaseUrl);
|
|
30
|
+
assertAllowedApiOrigin(apiBaseUrl, DEFAULT_API_BASE_URL, API_BASE_URL_ENV_VAR);
|
|
31
|
+
return { apiKey: envRunToken, apiBaseUrl, wsUrl };
|
|
32
|
+
}
|
|
33
|
+
const envApiKey = normalizeApiKey(process.env[API_KEY_ENV_VAR]);
|
|
34
|
+
if (envApiKey) {
|
|
35
|
+
const apiBaseUrl = getDefaultApiBaseUrl();
|
|
36
|
+
const wsUrl = getDefaultWsUrl(apiBaseUrl);
|
|
37
|
+
assertAllowedApiOrigin(apiBaseUrl, DEFAULT_API_BASE_URL, API_BASE_URL_ENV_VAR);
|
|
38
|
+
return { apiKey: envApiKey, apiBaseUrl, wsUrl };
|
|
39
|
+
}
|
|
40
|
+
// Only swallow IO / parse failures. Origin-allowlist / schema errors must
|
|
41
|
+
// bubble up so the user sees what's wrong instead of "not authenticated".
|
|
42
|
+
let raw;
|
|
43
|
+
try {
|
|
44
|
+
raw = await fs.readFile(getConfigPath(), 'utf-8');
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
let parsed;
|
|
50
|
+
try {
|
|
51
|
+
parsed = JSON.parse(raw);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
if (!parsed || typeof parsed !== 'object')
|
|
57
|
+
return null;
|
|
58
|
+
const record = parsed;
|
|
59
|
+
if (typeof record.apiKey !== 'string' || record.apiKey.length === 0)
|
|
60
|
+
return null;
|
|
61
|
+
const storedApiBaseUrlRaw = typeof record.apiBaseUrl === 'string' ? record.apiBaseUrl : null;
|
|
62
|
+
const storedWsUrlRaw = typeof record.wsUrl === 'string' ? record.wsUrl : null;
|
|
63
|
+
const envApiBaseUrl = normalizeUrl(process.env[API_BASE_URL_ENV_VAR]);
|
|
64
|
+
const envWsUrl = normalizeUrl(process.env[WS_URL_ENV_VAR]);
|
|
65
|
+
const storedApiBaseUrl = normalizeUrl(storedApiBaseUrlRaw) ?? DEFAULT_API_BASE_URL;
|
|
66
|
+
const apiBaseUrl = envApiBaseUrl ?? storedApiBaseUrl;
|
|
67
|
+
const wsUrl = envWsUrl ??
|
|
68
|
+
(envApiBaseUrl
|
|
69
|
+
? deriveWsUrl(apiBaseUrl)
|
|
70
|
+
: normalizeUrl(storedWsUrlRaw) ?? deriveWsUrl(apiBaseUrl));
|
|
71
|
+
assertAllowedApiOrigin(apiBaseUrl, storedApiBaseUrl, envApiBaseUrl ? API_BASE_URL_ENV_VAR : undefined);
|
|
72
|
+
if (envWsUrl) {
|
|
73
|
+
assertAllowedApiOrigin(wsUrl.replace(/^ws/, 'http'), deriveWsUrl(storedApiBaseUrl).replace(/^ws/, 'http'), WS_URL_ENV_VAR);
|
|
74
|
+
}
|
|
75
|
+
return { apiKey: record.apiKey, apiBaseUrl, wsUrl };
|
|
76
|
+
}
|
|
77
|
+
export async function writeConfig(config) {
|
|
78
|
+
const dir = getConfigDir();
|
|
79
|
+
await fs.mkdir(dir, { recursive: true });
|
|
80
|
+
if (process.platform !== 'win32') {
|
|
81
|
+
try {
|
|
82
|
+
await fs.chmod(dir, 0o700);
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
// best-effort — the file's 0600 below is the real safety
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const configPath = getConfigPath();
|
|
89
|
+
const normalizedConfig = {
|
|
90
|
+
...config,
|
|
91
|
+
apiBaseUrl: normalizeUrl(config.apiBaseUrl) ?? DEFAULT_API_BASE_URL,
|
|
92
|
+
wsUrl: normalizeUrl(config.wsUrl) ?? deriveWsUrl(config.apiBaseUrl),
|
|
93
|
+
};
|
|
94
|
+
// Atomic write: write to a per-process tmp file (so concurrent writes don't
|
|
95
|
+
// clobber each other), chmod it BEFORE the rename (so the final file is
|
|
96
|
+
// never world-readable on POSIX), then rename. `fs.rename` is atomic on
|
|
97
|
+
// POSIX for paths on the same filesystem; Windows replaces the destination
|
|
98
|
+
// atomically as well when the source/dest are on the same volume.
|
|
99
|
+
//
|
|
100
|
+
// `mode: 0o600` is passed to writeFile so the tmp file is created with the
|
|
101
|
+
// tight permission set from the very first byte — closes the small window
|
|
102
|
+
// where umask alone would have produced 0o644.
|
|
103
|
+
const tmpPath = `${configPath}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
|
|
104
|
+
try {
|
|
105
|
+
await fs.writeFile(tmpPath, JSON.stringify(normalizedConfig, null, 2), {
|
|
106
|
+
encoding: 'utf-8',
|
|
107
|
+
mode: 0o600,
|
|
108
|
+
});
|
|
109
|
+
if (process.platform !== 'win32') {
|
|
110
|
+
// Belt-and-suspenders: enforce 0600 even if the FS ignored the mode
|
|
111
|
+
// argument (some filesystems / Node versions do).
|
|
112
|
+
await fs.chmod(tmpPath, 0o600);
|
|
113
|
+
}
|
|
114
|
+
await fs.rename(tmpPath, configPath);
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
await fs.rm(tmpPath, { force: true }).catch(() => undefined);
|
|
118
|
+
throw err;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
export async function deleteConfig() {
|
|
122
|
+
try {
|
|
123
|
+
await fs.rm(getConfigPath());
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
// ignore missing file
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
function normalizeApiKey(value) {
|
|
130
|
+
const trimmed = value?.trim();
|
|
131
|
+
return trimmed || null;
|
|
132
|
+
}
|
|
133
|
+
function normalizeUrl(value) {
|
|
134
|
+
const trimmed = value?.trim();
|
|
135
|
+
if (!trimmed)
|
|
136
|
+
return null;
|
|
137
|
+
return trimmed.replace(/\/+$/, '');
|
|
138
|
+
}
|
|
139
|
+
function deriveWsUrl(apiBaseUrl) {
|
|
140
|
+
try {
|
|
141
|
+
const url = new URL(apiBaseUrl);
|
|
142
|
+
url.protocol =
|
|
143
|
+
url.protocol === 'https:' ? 'wss:' : url.protocol === 'http:' ? 'ws:' : url.protocol;
|
|
144
|
+
const pathname = url.pathname.replace(/\/+$/, '');
|
|
145
|
+
url.pathname = `${pathname || ''}/ws`;
|
|
146
|
+
url.search = '';
|
|
147
|
+
url.hash = '';
|
|
148
|
+
return url.toString().replace(/\/+$/, '');
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
return DEFAULT_WS_URL;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
function normalizeOrigin(value) {
|
|
155
|
+
const normalized = normalizeUrl(value);
|
|
156
|
+
if (!normalized)
|
|
157
|
+
return null;
|
|
158
|
+
try {
|
|
159
|
+
const parsed = new URL(normalized);
|
|
160
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')
|
|
161
|
+
return null;
|
|
162
|
+
return parsed.origin;
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
function isUnsafeOverrideEnabled() {
|
|
169
|
+
const raw = process.env[ALLOW_UNSAFE_SERVER_ORIGIN_ENV_VAR]?.trim().toLowerCase();
|
|
170
|
+
return raw === '1' || raw === 'true';
|
|
171
|
+
}
|
|
172
|
+
function isTrustedOrigin(origin) {
|
|
173
|
+
return (origin === normalizeOrigin(DEFAULT_API_BASE_URL) ||
|
|
174
|
+
origin === normalizeOrigin(LOCAL_API_BASE_URL));
|
|
175
|
+
}
|
|
176
|
+
function assertAllowedApiOrigin(candidateUrl, baselineUrl, envVar) {
|
|
177
|
+
const candidateOrigin = normalizeOrigin(candidateUrl);
|
|
178
|
+
const baselineOrigin = normalizeOrigin(baselineUrl);
|
|
179
|
+
if (!candidateOrigin || !baselineOrigin) {
|
|
180
|
+
throw new Error('AutoKap config contains an invalid server origin');
|
|
181
|
+
}
|
|
182
|
+
if (candidateOrigin === baselineOrigin || isTrustedOrigin(candidateOrigin)) {
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
if (isUnsafeOverrideEnabled()) {
|
|
186
|
+
process.stderr.write(`[autokap:warn] Allowing unsafe server override from ${baselineOrigin} to ${candidateOrigin} because ${ALLOW_UNSAFE_SERVER_ORIGIN_ENV_VAR}=1\n`);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
throw new Error(`Refusing unsafe server override to ${candidateOrigin}. Set ${ALLOW_UNSAFE_SERVER_ORIGIN_ENV_VAR}=1 to allow ${envVar ?? 'this override'} explicitly.`);
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* SSRF guard for any caller-supplied URL that the AutoKap stack will
|
|
193
|
+
* eventually fetch (MCP tool inputs — apiBaseUrl, proxyUrl, webhookUrl,
|
|
194
|
+
* loginUrl, project baseUrl, etc.).
|
|
195
|
+
*
|
|
196
|
+
* Rejects:
|
|
197
|
+
* - non-http(s) schemes (file://, javascript:, gopher://, data:, ...)
|
|
198
|
+
* - localhost / 127.0.0.0/8 / private IPv4 / link-local / IPv6 loopback
|
|
199
|
+
* - hostnames ending in `.local`, `.internal`
|
|
200
|
+
*
|
|
201
|
+
* When `AUTOKAP_ALLOW_UNSAFE_SERVER_ORIGIN=1`, the private-host check is
|
|
202
|
+
* relaxed for dev workflows. The scheme check is NEVER relaxed.
|
|
203
|
+
*
|
|
204
|
+
* `validatePublicHttpUrl` is the public alias used by MCP tools that accept
|
|
205
|
+
* user-provided URLs; `validateServerOrigin` is the alias used by core API
|
|
206
|
+
* client paths.
|
|
207
|
+
*/
|
|
208
|
+
export function validateServerOrigin(candidateUrl) {
|
|
209
|
+
validatePublicHttpUrl(candidateUrl, 'server origin');
|
|
210
|
+
}
|
|
211
|
+
export function validatePublicHttpUrl(candidateUrl, fieldLabel = 'URL') {
|
|
212
|
+
let parsed;
|
|
213
|
+
try {
|
|
214
|
+
parsed = new URL(candidateUrl);
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
throw new Error(`Invalid ${fieldLabel}: ${candidateUrl}`);
|
|
218
|
+
}
|
|
219
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
220
|
+
throw new Error(`Refusing ${fieldLabel} with unsupported scheme "${parsed.protocol}"; only http(s) are allowed.`);
|
|
221
|
+
}
|
|
222
|
+
if (isPrivateOrInternalHost(parsed.hostname)) {
|
|
223
|
+
if (isUnsafeOverrideEnabled()) {
|
|
224
|
+
process.stderr.write(`[autokap:warn] Allowing private/internal host ${parsed.hostname} for ${fieldLabel} because ${ALLOW_UNSAFE_SERVER_ORIGIN_ENV_VAR}=1\n`);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
throw new Error(`Refusing ${fieldLabel} pointing at a private or link-local host (${parsed.hostname}). ` +
|
|
228
|
+
`Set ${ALLOW_UNSAFE_SERVER_ORIGIN_ENV_VAR}=1 to override (dev/test only).`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
function isPrivateOrInternalHost(hostname) {
|
|
232
|
+
const lower = hostname.toLowerCase();
|
|
233
|
+
if (lower === 'localhost')
|
|
234
|
+
return true;
|
|
235
|
+
if (lower === '0.0.0.0' || lower === '::' || lower === '[::]')
|
|
236
|
+
return true;
|
|
237
|
+
if (lower.endsWith('.local') || lower.endsWith('.internal'))
|
|
238
|
+
return true;
|
|
239
|
+
if (lower === '::1' || lower === '[::1]')
|
|
240
|
+
return true;
|
|
241
|
+
// IPv4 — dotted-decimal AND obfuscated forms (octal/hex/decimal). Node's
|
|
242
|
+
// fetch normalizes these via libuv's getaddrinfo, so `http://2130706433/`
|
|
243
|
+
// resolves to 127.0.0.1 and would otherwise slip past a dotted-only check.
|
|
244
|
+
const ipv4 = parseIpv4(lower);
|
|
245
|
+
if (ipv4) {
|
|
246
|
+
const [a, b] = ipv4;
|
|
247
|
+
if (a === 10)
|
|
248
|
+
return true; // 10.0.0.0/8
|
|
249
|
+
if (a === 127)
|
|
250
|
+
return true; // loopback 127.0.0.0/8
|
|
251
|
+
if (a === 169 && b === 254)
|
|
252
|
+
return true; // link-local 169.254.0.0/16
|
|
253
|
+
if (a === 172 && b >= 16 && b <= 31)
|
|
254
|
+
return true; // 172.16.0.0/12
|
|
255
|
+
if (a === 192 && b === 168)
|
|
256
|
+
return true; // 192.168.0.0/16
|
|
257
|
+
if (a === 100 && b >= 64 && b <= 127)
|
|
258
|
+
return true; // CGNAT 100.64.0.0/10
|
|
259
|
+
if (a === 0)
|
|
260
|
+
return true; // 0.0.0.0/8 (this network)
|
|
261
|
+
if (a >= 224)
|
|
262
|
+
return true; // multicast + reserved
|
|
263
|
+
}
|
|
264
|
+
// Bracketed IPv6 — loopback, ULA, link-local, IPv4-mapped, 6to4-encoded
|
|
265
|
+
// loopback, NAT64 well-known prefix carrying private/loopback v4.
|
|
266
|
+
if (lower.startsWith('[') && lower.endsWith(']')) {
|
|
267
|
+
return isPrivateIpv6(lower.slice(1, -1));
|
|
268
|
+
}
|
|
269
|
+
return false;
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Parse an IPv4 literal that Node's fetch / getaddrinfo would accept,
|
|
273
|
+
* including the obfuscated forms attackers use to bypass naive checks:
|
|
274
|
+
* - dotted-decimal: 127.0.0.1
|
|
275
|
+
* - dotted with leading 0: 0177.0.0.1 (octal — 0177 = 127)
|
|
276
|
+
* - dotted with 0x prefix: 0x7f.0.0.1 (hex)
|
|
277
|
+
* - single 32-bit integer: 2130706433 (decimal — equals 127.0.0.1)
|
|
278
|
+
* - single hex integer: 0x7f000001
|
|
279
|
+
*
|
|
280
|
+
* Returns the octet tuple [a, b, c, d] when recognized, else null.
|
|
281
|
+
* Each octet is clamped on parse so malformed inputs like 999.0.0.0
|
|
282
|
+
* return null instead of allowing the SSRF guard to mis-classify them.
|
|
283
|
+
*/
|
|
284
|
+
function parseIpv4(literal) {
|
|
285
|
+
const parts = literal.split('.');
|
|
286
|
+
if (parts.length === 4) {
|
|
287
|
+
const octets = [];
|
|
288
|
+
for (const part of parts) {
|
|
289
|
+
const n = parseObfuscatedOctet(part);
|
|
290
|
+
if (n === null || n < 0 || n > 255)
|
|
291
|
+
return null;
|
|
292
|
+
octets.push(n);
|
|
293
|
+
}
|
|
294
|
+
return octets;
|
|
295
|
+
}
|
|
296
|
+
if (parts.length === 1) {
|
|
297
|
+
const n = parseObfuscatedInt(parts[0]);
|
|
298
|
+
if (n === null || n < 0 || n > 0xff_ff_ff_ff)
|
|
299
|
+
return null;
|
|
300
|
+
return [
|
|
301
|
+
(n >>> 24) & 0xff,
|
|
302
|
+
(n >>> 16) & 0xff,
|
|
303
|
+
(n >>> 8) & 0xff,
|
|
304
|
+
n & 0xff,
|
|
305
|
+
];
|
|
306
|
+
}
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
309
|
+
function parseObfuscatedOctet(part) {
|
|
310
|
+
// Reject empties / spaces / sign.
|
|
311
|
+
if (part.length === 0 || /[^0-9a-fx]/.test(part))
|
|
312
|
+
return null;
|
|
313
|
+
if (part.startsWith('0x') || part.startsWith('0X')) {
|
|
314
|
+
const hex = part.slice(2);
|
|
315
|
+
if (hex.length === 0 || /[^0-9a-f]/.test(hex))
|
|
316
|
+
return null;
|
|
317
|
+
return Number.parseInt(hex, 16);
|
|
318
|
+
}
|
|
319
|
+
if (part.length > 1 && part.startsWith('0')) {
|
|
320
|
+
if (/[^0-7]/.test(part))
|
|
321
|
+
return null;
|
|
322
|
+
return Number.parseInt(part, 8);
|
|
323
|
+
}
|
|
324
|
+
if (/[^0-9]/.test(part))
|
|
325
|
+
return null;
|
|
326
|
+
return Number.parseInt(part, 10);
|
|
327
|
+
}
|
|
328
|
+
function parseObfuscatedInt(part) {
|
|
329
|
+
if (part.startsWith('0x') || part.startsWith('0X')) {
|
|
330
|
+
const hex = part.slice(2);
|
|
331
|
+
if (hex.length === 0 || /[^0-9a-f]/.test(hex))
|
|
332
|
+
return null;
|
|
333
|
+
return Number.parseInt(hex, 16);
|
|
334
|
+
}
|
|
335
|
+
if (part.length > 1 && part.startsWith('0')) {
|
|
336
|
+
if (/[^0-7]/.test(part))
|
|
337
|
+
return null;
|
|
338
|
+
return Number.parseInt(part, 8);
|
|
339
|
+
}
|
|
340
|
+
if (/[^0-9]/.test(part))
|
|
341
|
+
return null;
|
|
342
|
+
return Number.parseInt(part, 10);
|
|
343
|
+
}
|
|
344
|
+
function isPrivateIpv6(ipv6) {
|
|
345
|
+
if (ipv6 === '::1')
|
|
346
|
+
return true;
|
|
347
|
+
if (ipv6.startsWith('fc') || ipv6.startsWith('fd'))
|
|
348
|
+
return true; // ULA fc00::/7
|
|
349
|
+
if (ipv6.startsWith('fe80'))
|
|
350
|
+
return true; // link-local
|
|
351
|
+
// IPv4-mapped (RFC 4291): canonicalizes to either `::ffff:a.b.c.d` or the
|
|
352
|
+
// hex-pair shorthand `::ffff:H:L`. Node's URL canonicalizes dotted form to
|
|
353
|
+
// the hex-pair form, so both must be handled.
|
|
354
|
+
if (ipv6.startsWith('::ffff:')) {
|
|
355
|
+
const tail = ipv6.slice('::ffff:'.length);
|
|
356
|
+
return embeddedV4IsPrivate(tail);
|
|
357
|
+
}
|
|
358
|
+
if (ipv6.startsWith('::ffff:0:')) {
|
|
359
|
+
const tail = ipv6.slice('::ffff:0:'.length);
|
|
360
|
+
return embeddedV4IsPrivate(tail);
|
|
361
|
+
}
|
|
362
|
+
// Bare hex-pair shorthand (no ::ffff prefix), e.g. `::7f00:1` = 127.0.0.1.
|
|
363
|
+
if (ipv6.startsWith('::') && !ipv6.startsWith(':::')) {
|
|
364
|
+
const tail = ipv6.slice(2);
|
|
365
|
+
if (/^[0-9a-f]{1,4}:[0-9a-f]{1,4}$/.test(tail)) {
|
|
366
|
+
return embeddedV4IsPrivate(tail);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
// 6to4-encoded prefix `2002::/16` — when the embedded IPv4 is private/
|
|
370
|
+
// loopback, the route lands in those networks despite the public-looking
|
|
371
|
+
// outer prefix.
|
|
372
|
+
const sixToFour = ipv6.match(/^2002:([0-9a-f]{1,4}):([0-9a-f]{1,4})/);
|
|
373
|
+
if (sixToFour) {
|
|
374
|
+
return embeddedV4IsPrivate(`${sixToFour[1]}:${sixToFour[2]}`);
|
|
375
|
+
}
|
|
376
|
+
// NAT64 well-known prefix `64:ff9b::/96` — last 32 bits are the embedded v4.
|
|
377
|
+
const nat64 = ipv6.match(/^64:ff9b::([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
|
|
378
|
+
if (nat64) {
|
|
379
|
+
return embeddedV4IsPrivate(`${nat64[1]}:${nat64[2]}`);
|
|
380
|
+
}
|
|
381
|
+
return false;
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* Parse a 32-bit IPv4 embedded in an IPv6 address. Accepts both the
|
|
385
|
+
* dotted-decimal form (`127.0.0.1`) and the hex-pair shorthand (`7f00:1`).
|
|
386
|
+
*/
|
|
387
|
+
function embeddedV4IsPrivate(tail) {
|
|
388
|
+
const inner = parseIpv4(tail);
|
|
389
|
+
if (inner) {
|
|
390
|
+
return isPrivateOrInternalHost(`${inner[0]}.${inner[1]}.${inner[2]}.${inner[3]}`);
|
|
391
|
+
}
|
|
392
|
+
const colon = tail.split(':');
|
|
393
|
+
if (colon.length === 2) {
|
|
394
|
+
const hi = Number.parseInt(colon[0], 16);
|
|
395
|
+
const lo = Number.parseInt(colon[1], 16);
|
|
396
|
+
if (Number.isFinite(hi) && Number.isFinite(lo) && hi >= 0 && hi <= 0xffff && lo >= 0 && lo <= 0xffff) {
|
|
397
|
+
const dotted = `${(hi >>> 8) & 0xff}.${hi & 0xff}.${(lo >>> 8) & 0xff}.${lo & 0xff}`;
|
|
398
|
+
return isPrivateOrInternalHost(dotted);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
return false;
|
|
402
|
+
}
|
|
403
|
+
export { DEFAULT_API_BASE_URL, DEFAULT_WS_URL, LOCAL_API_BASE_URL, API_KEY_ENV_VAR, RUN_TOKEN_ENV_VAR, API_BASE_URL_ENV_VAR, WS_URL_ENV_VAR, ALLOW_UNSAFE_SERVER_ORIGIN_ENV_VAR, assertAllowedApiOrigin, };
|
|
404
|
+
//# sourceMappingURL=config.js.map
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export declare function buildEndpointAssetUrl(apiBaseUrl: string, endpointId: string): string;
|
|
2
|
+
export interface EndpointUrlParams {
|
|
3
|
+
lang: string;
|
|
4
|
+
theme: string;
|
|
5
|
+
target: string;
|
|
6
|
+
w?: string;
|
|
7
|
+
quality?: string;
|
|
8
|
+
format?: string;
|
|
9
|
+
scale?: string;
|
|
10
|
+
}
|
|
11
|
+
export declare function buildEndpointUrlParams(assetType: string): EndpointUrlParams;
|
|
12
|
+
export declare function toCsv(rows: Array<Record<string, unknown>>, columns: string[]): string;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export function buildEndpointAssetUrl(apiBaseUrl, endpointId) {
|
|
2
|
+
// Encode the id even though today it's always a UUID — if the backend ever
|
|
3
|
+
// adopts a non-opaque id format (timestamp-prefixed, slug-shaped) that
|
|
4
|
+
// includes `/`, `?`, `#`, or `..`, raw interpolation would silently
|
|
5
|
+
// produce a malformed URL or a path-traversal vector.
|
|
6
|
+
return `${apiBaseUrl.replace(/\/+$/, '')}/api/v1/assets/${encodeURIComponent(endpointId)}`;
|
|
7
|
+
}
|
|
8
|
+
export function buildEndpointUrlParams(assetType) {
|
|
9
|
+
const isClip = assetType === 'clip';
|
|
10
|
+
return {
|
|
11
|
+
lang: '<lang>',
|
|
12
|
+
theme: '<theme>',
|
|
13
|
+
target: '<target>',
|
|
14
|
+
...(isClip
|
|
15
|
+
? { format: 'mp4|webm|gif' }
|
|
16
|
+
: { w: '<width>', quality: '<quality>', format: 'png|jpeg|webp', scale: '1|2|3' }),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
export function toCsv(rows, columns) {
|
|
20
|
+
const escape = (value) => {
|
|
21
|
+
if (value === null || value === undefined)
|
|
22
|
+
return '';
|
|
23
|
+
const str = String(value);
|
|
24
|
+
if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) {
|
|
25
|
+
return `"${str.replace(/"/g, '""')}"`;
|
|
26
|
+
}
|
|
27
|
+
return str;
|
|
28
|
+
};
|
|
29
|
+
const header = columns.map(escape).join(',');
|
|
30
|
+
const body = rows.map((row) => columns.map((col) => escape(row[col])).join(',')).join('\n');
|
|
31
|
+
return `${header}\n${body}`;
|
|
32
|
+
}
|
|
33
|
+
//# sourceMappingURL=endpoint-helpers.js.map
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
package/dist/logger.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
|
2
|
+
export interface Logger {
|
|
3
|
+
debug: (message: string, ...args: unknown[]) => void;
|
|
4
|
+
info: (message: string, ...args: unknown[]) => void;
|
|
5
|
+
warn: (message: string, ...args: unknown[]) => void;
|
|
6
|
+
error: (message: string, ...args: unknown[]) => void;
|
|
7
|
+
}
|
|
8
|
+
export interface CreateLoggerOptions {
|
|
9
|
+
/**
|
|
10
|
+
* Minimum log level. Defaults to `process.env.AUTOKAP_LOG` (case-insensitive
|
|
11
|
+
* one of 'debug' | 'info' | 'warn' | 'error') and falls back to 'info'.
|
|
12
|
+
*/
|
|
13
|
+
minLevel?: LogLevel;
|
|
14
|
+
}
|
|
15
|
+
export declare function createStderrLogger(options?: CreateLoggerOptions): Logger;
|
|
16
|
+
declare function redactString(input: string): string;
|
|
17
|
+
declare function redactValue(value: unknown, seen?: WeakSet<object>): unknown;
|
|
18
|
+
export { redactString, redactValue };
|
|
19
|
+
export declare const __test: {
|
|
20
|
+
redactString: typeof redactString;
|
|
21
|
+
redactValue: typeof redactValue;
|
|
22
|
+
};
|
package/dist/logger.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
const LEVEL_ORDER = { debug: 10, info: 20, warn: 30, error: 40 };
|
|
2
|
+
// AutoKap API/run-token prefixes — leak detection. Pattern is intentionally
|
|
3
|
+
// broad: catches `ak_cli_<hex>`, `ak_run_<hex>`, and any other `ak_<scope>_<id>`
|
|
4
|
+
// that's at least 6 chars long so we never accidentally log a real key.
|
|
5
|
+
const API_KEY_PATTERN = /\bak_[a-z]+_[A-Za-z0-9_-]{6,}\b/g;
|
|
6
|
+
const BEARER_PATTERN = /\bBearer\s+[A-Za-z0-9._\-]{6,}/gi;
|
|
7
|
+
const SENSITIVE_KEY_PATTERN = /(api[_-]?key|password|token|bearer|secret|authorization)/i;
|
|
8
|
+
function format(level, message) {
|
|
9
|
+
return `[autokap:${level}] ${message}`;
|
|
10
|
+
}
|
|
11
|
+
function resolveMinLevel(explicit) {
|
|
12
|
+
if (explicit)
|
|
13
|
+
return explicit;
|
|
14
|
+
const env = process.env.AUTOKAP_LOG?.trim().toLowerCase();
|
|
15
|
+
if (env && env in LEVEL_ORDER)
|
|
16
|
+
return env;
|
|
17
|
+
return 'info';
|
|
18
|
+
}
|
|
19
|
+
export function createStderrLogger(options = {}) {
|
|
20
|
+
const minLevel = resolveMinLevel(options.minLevel);
|
|
21
|
+
const minRank = LEVEL_ORDER[minLevel];
|
|
22
|
+
const write = (level, message, args) => {
|
|
23
|
+
if (LEVEL_ORDER[level] < minRank)
|
|
24
|
+
return;
|
|
25
|
+
const line = format(level, redactString(message));
|
|
26
|
+
if (args.length > 0) {
|
|
27
|
+
process.stderr.write(`${line} ${args.map(serialize).join(' ')}\n`);
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
process.stderr.write(`${line}\n`);
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
return {
|
|
34
|
+
debug: (msg, ...args) => write('debug', msg, args),
|
|
35
|
+
info: (msg, ...args) => write('info', msg, args),
|
|
36
|
+
warn: (msg, ...args) => write('warn', msg, args),
|
|
37
|
+
error: (msg, ...args) => write('error', msg, args),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
function redactString(input) {
|
|
41
|
+
// Bearer first — otherwise the inner ak_… pattern matches first and the
|
|
42
|
+
// Bearer suffix won't recognize the masked literal as a Bearer token.
|
|
43
|
+
return input.replace(BEARER_PATTERN, 'Bearer ***REDACTED').replace(API_KEY_PATTERN, 'ak_***_REDACTED');
|
|
44
|
+
}
|
|
45
|
+
function redactValue(value, seen = new WeakSet()) {
|
|
46
|
+
if (typeof value === 'string')
|
|
47
|
+
return redactString(value);
|
|
48
|
+
if (value === null || value === undefined)
|
|
49
|
+
return value;
|
|
50
|
+
if (typeof value !== 'object')
|
|
51
|
+
return value;
|
|
52
|
+
if (seen.has(value))
|
|
53
|
+
return '[Circular]';
|
|
54
|
+
seen.add(value);
|
|
55
|
+
if (Array.isArray(value))
|
|
56
|
+
return value.map((entry) => redactValue(entry, seen));
|
|
57
|
+
const out = {};
|
|
58
|
+
for (const [key, raw] of Object.entries(value)) {
|
|
59
|
+
if (SENSITIVE_KEY_PATTERN.test(key)) {
|
|
60
|
+
out[key] = '[redacted]';
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
out[key] = redactValue(raw, seen);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
function serialize(value) {
|
|
69
|
+
if (value instanceof Error) {
|
|
70
|
+
const stack = value.stack ?? value.message;
|
|
71
|
+
const cause = value.cause;
|
|
72
|
+
const causeSuffix = cause ? ` (cause: ${redactString(String(cause))})` : '';
|
|
73
|
+
return `${redactString(stack)}${causeSuffix}`;
|
|
74
|
+
}
|
|
75
|
+
if (typeof value === 'string')
|
|
76
|
+
return redactString(value);
|
|
77
|
+
try {
|
|
78
|
+
return JSON.stringify(redactValue(value));
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return redactString(String(value));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
// Public for callers that need to redact a value before surfacing it to an
|
|
85
|
+
// MCP client (e.g. `errorResult.responseBody`, tool error envelopes). The
|
|
86
|
+
// canonical entry point — using the logger's own redaction keeps the
|
|
87
|
+
// secrets-on-the-wire definition in one place.
|
|
88
|
+
export { redactString, redactValue };
|
|
89
|
+
// Backwards-compat alias retained for older tests; prefer the named exports.
|
|
90
|
+
export const __test = { redactString, redactValue };
|
|
91
|
+
//# sourceMappingURL=logger.js.map
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export interface AutokapConfig {
|
|
2
|
+
apiKey: string;
|
|
3
|
+
apiBaseUrl: string;
|
|
4
|
+
wsUrl: string;
|
|
5
|
+
}
|
|
6
|
+
export interface ValidateResponse {
|
|
7
|
+
valid: boolean;
|
|
8
|
+
userId?: string;
|
|
9
|
+
keyType?: string;
|
|
10
|
+
email?: string;
|
|
11
|
+
name?: string;
|
|
12
|
+
}
|
|
13
|
+
export type CheckStatus = 'ok' | 'warn' | 'fail';
|
|
14
|
+
export interface CheckResult {
|
|
15
|
+
name: string;
|
|
16
|
+
status: CheckStatus;
|
|
17
|
+
message: string;
|
|
18
|
+
fixCommand?: string;
|
|
19
|
+
}
|
package/dist/types.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@autokap/core",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Shared core library for AutoKap CLI and MCP server",
|
|
5
|
+
"license": "ISC",
|
|
6
|
+
"author": "AutoKap",
|
|
7
|
+
"homepage": "https://autokap.app",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/AutoKap/autokap.git",
|
|
11
|
+
"directory": "packages/core"
|
|
12
|
+
},
|
|
13
|
+
"publishConfig": {
|
|
14
|
+
"access": "public"
|
|
15
|
+
},
|
|
16
|
+
"type": "module",
|
|
17
|
+
"main": "./dist/index.js",
|
|
18
|
+
"types": "./dist/index.d.ts",
|
|
19
|
+
"exports": {
|
|
20
|
+
".": {
|
|
21
|
+
"types": "./dist/index.d.ts",
|
|
22
|
+
"default": "./dist/index.js"
|
|
23
|
+
},
|
|
24
|
+
"./config": {
|
|
25
|
+
"types": "./dist/config.d.ts",
|
|
26
|
+
"default": "./dist/config.js"
|
|
27
|
+
},
|
|
28
|
+
"./api-client": {
|
|
29
|
+
"types": "./dist/api-client.d.ts",
|
|
30
|
+
"default": "./dist/api-client.js"
|
|
31
|
+
},
|
|
32
|
+
"./endpoint-helpers": {
|
|
33
|
+
"types": "./dist/endpoint-helpers.d.ts",
|
|
34
|
+
"default": "./dist/endpoint-helpers.js"
|
|
35
|
+
},
|
|
36
|
+
"./types": {
|
|
37
|
+
"types": "./dist/types.d.ts",
|
|
38
|
+
"default": "./dist/types.js"
|
|
39
|
+
},
|
|
40
|
+
"./logger": {
|
|
41
|
+
"types": "./dist/logger.d.ts",
|
|
42
|
+
"default": "./dist/logger.js"
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
"scripts": {
|
|
46
|
+
"build": "tsc",
|
|
47
|
+
"typecheck": "tsc --noEmit",
|
|
48
|
+
"test": "vitest run --root ../..",
|
|
49
|
+
"prepublishOnly": "npm run build && node -e \"require('node:fs').accessSync('dist/index.js'); require('node:fs').accessSync('dist/index.d.ts');\""
|
|
50
|
+
},
|
|
51
|
+
"engines": {
|
|
52
|
+
"node": ">=20"
|
|
53
|
+
},
|
|
54
|
+
"files": [
|
|
55
|
+
"dist/**/*.js",
|
|
56
|
+
"dist/**/*.d.ts",
|
|
57
|
+
"!dist/**/*.js.map"
|
|
58
|
+
],
|
|
59
|
+
"dependencies": {},
|
|
60
|
+
"devDependencies": {}
|
|
61
|
+
}
|