@infimind/video-mcp-cli 1.0.11 → 1.0.12
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 +43 -0
- package/dist/auth/credentialWriter.js +84 -0
- package/dist/auth/exchange.js +61 -0
- package/dist/auth/installationId.js +36 -0
- package/dist/auth/login.js +77 -0
- package/dist/auth/logout.js +34 -0
- package/dist/auth/loopback.js +153 -0
- package/dist/auth/paths.js +30 -0
- package/dist/auth/pkce.js +11 -0
- package/dist/auth/profile.js +15 -0
- package/dist/auth/redact.js +63 -0
- package/dist/auth/status.js +31 -0
- package/dist/creativeTools.js +251 -0
- package/dist/index.js +139 -39
- package/dist/server.js +392 -152
- package/dist/videoRules.js +28 -10
- package/package.json +34 -7
- package/dist/index.d.ts +0 -2
- package/dist/server.d.ts +0 -11
- package/dist/videoRules.d.ts +0 -49
package/README.md
CHANGED
|
@@ -264,6 +264,49 @@ Image-to-video:
|
|
|
264
264
|
|
|
265
265
|
After dry run succeeds and the estimated credits look correct, remove `dryRun` or set it to `false` to create the real task. After creating a task, use `get_video_status` with the returned `taskId` to query progress and video links.
|
|
266
266
|
|
|
267
|
+
### `create_motion_video_remake_task` / `get_motion_video_remake_status`
|
|
268
|
+
|
|
269
|
+
Create and poll a motion video remake task using a character image and an MP4 motion reference. Local files and public URLs are supported.
|
|
270
|
+
|
|
271
|
+
```json
|
|
272
|
+
{
|
|
273
|
+
"sourceImage": "/path/to/person.jpg",
|
|
274
|
+
"referenceVideo": "/path/to/motion.mp4",
|
|
275
|
+
"mode": "move",
|
|
276
|
+
"quality": "std",
|
|
277
|
+
"count": 1,
|
|
278
|
+
"referenceVideoDurationSeconds": 12,
|
|
279
|
+
"dryRun": true
|
|
280
|
+
}
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
For `dryRun`, `referenceVideoDurationSeconds` is an optional estimate hint. If it is omitted, media validation still runs but credit fields are returned as `null`; the hint is never treated as detected media metadata. During formal creation, the backend ignores the hint, uploads the MP4 to OSS, reads the authoritative duration and dimensions through OSS `video/info`, and bills from the unrounded duration. Images are limited to 5 MB; MP4 references are limited to 100 MB and 30 seconds. After preflight succeeds, set `dryRun` to `false`, then poll the returned `taskId` with `get_motion_video_remake_status`.
|
|
284
|
+
|
|
285
|
+
### `create_ecommerce_storyboard_task` / `get_ecommerce_storyboard_status`
|
|
286
|
+
|
|
287
|
+
Create and poll an ecommerce storyboard with a complete story, per-scene descriptions, video prompts, and sketch images.
|
|
288
|
+
|
|
289
|
+
```json
|
|
290
|
+
{
|
|
291
|
+
"productName": "Smart Watch",
|
|
292
|
+
"productDescription": "Lightweight watch with long battery life and fitness tracking",
|
|
293
|
+
"targetAudience": "Young professionals",
|
|
294
|
+
"videoStyle": "Energetic product demo",
|
|
295
|
+
"productImage": "/path/to/product.png",
|
|
296
|
+
"targetDuration": 60,
|
|
297
|
+
"language": "en",
|
|
298
|
+
"orientation": "vertical",
|
|
299
|
+
"dryRun": true
|
|
300
|
+
}
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
Storyboard preflight estimates a fixed cost of 2 credits. Set `dryRun` to `false` to create the task, then call `get_ecommerce_storyboard_status`. Storyboard scenes are not automatically submitted as final video tasks; pass a selected scene prompt and image to `create_video_task` when needed.
|
|
304
|
+
|
|
305
|
+
### Backend deployment requirements for 1.1.0
|
|
306
|
+
|
|
307
|
+
- Apply `drizzle/migrations/0070_add_creative_billing_state.sql` after `0069_add_output_upscale_attempt.sql` and before deploying the application and motion poller. The additive columns default historical creative rows to already charged.
|
|
308
|
+
- Verify that the configured OSS bucket supports `video/info` and that the application identity can read media metadata before enabling the new tools. The application can be rolled back without dropping the additive columns.
|
|
309
|
+
|
|
267
310
|
## Development
|
|
268
311
|
|
|
269
312
|
```bash
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Atomic credential file writer.
|
|
3
|
+
*
|
|
4
|
+
* Writes to a temp file in the same directory, then renames over the
|
|
5
|
+
* target. Sets directory mode 0700 and file mode 0600 after the rename.
|
|
6
|
+
*/
|
|
7
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync, openSync, closeSync, } from "node:fs";
|
|
8
|
+
import { dirname, join } from "node:path";
|
|
9
|
+
import { credentialsDir, credentialsFilePath } from "./paths.js";
|
|
10
|
+
function assertPermissions() {
|
|
11
|
+
const dir = credentialsDir();
|
|
12
|
+
if (!existsSync(dir)) {
|
|
13
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
14
|
+
}
|
|
15
|
+
try {
|
|
16
|
+
chmodSync(dir, 0o700);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
// best-effort
|
|
20
|
+
}
|
|
21
|
+
try {
|
|
22
|
+
const st = statSync(dir);
|
|
23
|
+
if (process.platform !== "win32") {
|
|
24
|
+
const mode = st.mode & 0o777;
|
|
25
|
+
if (mode & 0o077) {
|
|
26
|
+
throw new Error(`credentials directory is world/group accessible (mode=${mode.toString(8)})`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
if (err instanceof Error && err.message.startsWith("credentials directory")) {
|
|
32
|
+
throw err;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export function writeCredentials(payload) {
|
|
37
|
+
assertPermissions();
|
|
38
|
+
const target = credentialsFilePath();
|
|
39
|
+
const tmp = join(dirname(target), `.${Date.now()}.${process.pid}.tmp`);
|
|
40
|
+
const json = JSON.stringify(payload, null, 2);
|
|
41
|
+
const fd = openSync(tmp, "wx", 0o600);
|
|
42
|
+
try {
|
|
43
|
+
writeFileSync(fd, json, { encoding: "utf8" });
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
closeSync(fd);
|
|
47
|
+
}
|
|
48
|
+
renameSync(tmp, target);
|
|
49
|
+
try {
|
|
50
|
+
chmodSync(target, 0o600);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// best-effort
|
|
54
|
+
}
|
|
55
|
+
return target;
|
|
56
|
+
}
|
|
57
|
+
export function readCredentials() {
|
|
58
|
+
const target = credentialsFilePath();
|
|
59
|
+
if (!existsSync(target))
|
|
60
|
+
return null;
|
|
61
|
+
try {
|
|
62
|
+
const st = statSync(target);
|
|
63
|
+
if (process.platform !== "win32") {
|
|
64
|
+
const mode = st.mode & 0o777;
|
|
65
|
+
if (mode & 0o077) {
|
|
66
|
+
throw new Error(`credentials file is world/group accessible (mode=${mode.toString(8)})`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const text = readFileSync(target, "utf8");
|
|
70
|
+
return JSON.parse(text);
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
if (err instanceof SyntaxError)
|
|
74
|
+
return null;
|
|
75
|
+
throw err;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
export function deleteCredentials() {
|
|
79
|
+
const target = credentialsFilePath();
|
|
80
|
+
if (!existsSync(target))
|
|
81
|
+
return false;
|
|
82
|
+
unlinkSync(target);
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token exchange helpers. The CLI never logs the raw token, code, or
|
|
3
|
+
* verifier; pass them through the redacting writers in `redact.ts`.
|
|
4
|
+
*/
|
|
5
|
+
const DEFAULT_API_URL = "https://aigc-next.iclip.cn/api";
|
|
6
|
+
export async function exchangeAuthorizationCode(input) {
|
|
7
|
+
const url = new URL("/api/oauth/workbuddy/token", input.apiBaseUrl).toString();
|
|
8
|
+
const body = {
|
|
9
|
+
grant_type: "authorization_code",
|
|
10
|
+
code: input.code,
|
|
11
|
+
redirect_uri: input.redirectUri,
|
|
12
|
+
code_verifier: input.codeVerifier,
|
|
13
|
+
installation_id: input.installationId,
|
|
14
|
+
client_id: "infimind-video-workbuddy-cli",
|
|
15
|
+
};
|
|
16
|
+
let resp;
|
|
17
|
+
try {
|
|
18
|
+
resp = await fetch(url, {
|
|
19
|
+
method: "POST",
|
|
20
|
+
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
|
21
|
+
body: JSON.stringify(body),
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
catch (err) {
|
|
25
|
+
return { ok: false, reason: "network_error" };
|
|
26
|
+
}
|
|
27
|
+
if (!resp.ok) {
|
|
28
|
+
return { ok: false, reason: `http_${resp.status}` };
|
|
29
|
+
}
|
|
30
|
+
let json;
|
|
31
|
+
try {
|
|
32
|
+
json = (await resp.json());
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return { ok: false, reason: "invalid_response" };
|
|
36
|
+
}
|
|
37
|
+
if (!json.access_token || !json.token_type) {
|
|
38
|
+
return { ok: false, reason: "invalid_response" };
|
|
39
|
+
}
|
|
40
|
+
return { ok: true, token: json };
|
|
41
|
+
}
|
|
42
|
+
export async function revokeWorkbuddyToken(input) {
|
|
43
|
+
const url = new URL("/api/oauth/workbuddy/revoke", input.apiBaseUrl).toString();
|
|
44
|
+
try {
|
|
45
|
+
const resp = await fetch(url, {
|
|
46
|
+
method: "POST",
|
|
47
|
+
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
|
48
|
+
body: JSON.stringify({
|
|
49
|
+
token: input.accessToken,
|
|
50
|
+
installation_id: input.installationId,
|
|
51
|
+
}),
|
|
52
|
+
});
|
|
53
|
+
return { ok: resp.ok, status: resp.status };
|
|
54
|
+
}
|
|
55
|
+
catch (err) {
|
|
56
|
+
return { ok: false, status: 0, reason: "network_error" };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
export function defaultApiUrl() {
|
|
60
|
+
return process.env.SORA_API_URL || DEFAULT_API_URL;
|
|
61
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stable per-installation identifier. Generated once and persisted under
|
|
3
|
+
* ~/.workbuddy/connectors/infimind-video/auth/installation_id
|
|
4
|
+
* so the same machine always produces the same id. We do not transmit the
|
|
5
|
+
* hostname to the server; only the hash of this id is sent.
|
|
6
|
+
*/
|
|
7
|
+
import { randomBytes } from "node:crypto";
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from "node:fs";
|
|
9
|
+
import { dirname } from "node:path";
|
|
10
|
+
import { installationIdFilePath } from "./paths.js";
|
|
11
|
+
const INSTALLATION_ID_LENGTH = 32; // 32 bytes base64url = 43 chars
|
|
12
|
+
export function readOrCreateInstallationId() {
|
|
13
|
+
const path = installationIdFilePath();
|
|
14
|
+
if (existsSync(path)) {
|
|
15
|
+
try {
|
|
16
|
+
const value = readFileSync(path, "utf8").trim();
|
|
17
|
+
if (/^[A-Za-z0-9_-]{16,128}$/.test(value)) {
|
|
18
|
+
return value;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
// fall through to regeneration
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
const generated = randomBytes(INSTALLATION_ID_LENGTH).toString("base64url");
|
|
26
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
27
|
+
writeFileSync(path, generated, { encoding: "utf8", mode: 0o600 });
|
|
28
|
+
try {
|
|
29
|
+
chmodSync(dirname(path), 0o700);
|
|
30
|
+
chmodSync(path, 0o600);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
// best-effort; CI / Windows may not support chmod
|
|
34
|
+
}
|
|
35
|
+
return generated;
|
|
36
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `sora-mcp auth login` implementation.
|
|
3
|
+
*
|
|
4
|
+
* Sequence:
|
|
5
|
+
* 1. Read or generate the installationId (persisted on disk).
|
|
6
|
+
* 2. Bind 127.0.0.1 to a random port and prepare state + PKCE challenge.
|
|
7
|
+
* 3. Print the HTTPS authorization URL within 10 s of invocation.
|
|
8
|
+
* 4. Wait for the loopback callback or 5-minute timeout.
|
|
9
|
+
* 5. Exchange the code with PKCE verifier + installation id.
|
|
10
|
+
* 6. Persist the credential atomically.
|
|
11
|
+
*/
|
|
12
|
+
import { bindLoopback } from "./loopback.js";
|
|
13
|
+
import { readOrCreateInstallationId } from "./installationId.js";
|
|
14
|
+
import { exchangeAuthorizationCode, defaultApiUrl, } from "./exchange.js";
|
|
15
|
+
import { writeCredentials } from "./credentialWriter.js";
|
|
16
|
+
const TIMEOUT_MS = 5 * 60 * 1000;
|
|
17
|
+
const PRINT_URL_DEADLINE_MS = 10_000;
|
|
18
|
+
const CLI_VERSION = "1.0.12";
|
|
19
|
+
function detectPlatform() {
|
|
20
|
+
return process.platform;
|
|
21
|
+
}
|
|
22
|
+
export async function runLogin() {
|
|
23
|
+
const installationId = readOrCreateInstallationId();
|
|
24
|
+
const apiBaseUrl = defaultApiUrl();
|
|
25
|
+
const platform = detectPlatform();
|
|
26
|
+
const binding = await bindLoopback();
|
|
27
|
+
const params = new URLSearchParams({
|
|
28
|
+
response_type: "code",
|
|
29
|
+
client_id: "infimind-video-workbuddy-cli",
|
|
30
|
+
redirect_uri: binding.url,
|
|
31
|
+
state: binding.state,
|
|
32
|
+
code_challenge: binding.codeChallenge,
|
|
33
|
+
code_challenge_method: "S256",
|
|
34
|
+
installation_id: installationId,
|
|
35
|
+
platform,
|
|
36
|
+
cli_version: CLI_VERSION,
|
|
37
|
+
});
|
|
38
|
+
const authUrl = `${apiBaseUrl.replace(/\/+$/, "")}/oauth/workbuddy/authorize?${params.toString()}`;
|
|
39
|
+
// Print URL within 10s.
|
|
40
|
+
const printTimer = setTimeout(() => {
|
|
41
|
+
process.stdout.write(`\n[workbuddy-auth] login URL not printed within 10s; binding kept\n`);
|
|
42
|
+
}, PRINT_URL_DEADLINE_MS);
|
|
43
|
+
process.stdout.write(`\n[workbuddy-auth] open this URL in your browser:\n${authUrl}\n`);
|
|
44
|
+
clearTimeout(printTimer);
|
|
45
|
+
let callback;
|
|
46
|
+
try {
|
|
47
|
+
callback = await binding.awaitCallback(TIMEOUT_MS);
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
await binding.close();
|
|
51
|
+
return { ok: false, reason: err.message || "callback_failed" };
|
|
52
|
+
}
|
|
53
|
+
await binding.close();
|
|
54
|
+
const exchanged = await exchangeAuthorizationCode({
|
|
55
|
+
apiBaseUrl,
|
|
56
|
+
redirectUri: binding.url,
|
|
57
|
+
code: callback.code,
|
|
58
|
+
codeVerifier: binding.codeVerifier,
|
|
59
|
+
installationId,
|
|
60
|
+
platform,
|
|
61
|
+
cliVersion: CLI_VERSION,
|
|
62
|
+
});
|
|
63
|
+
if (!exchanged.ok || !exchanged.token) {
|
|
64
|
+
return { ok: false, reason: exchanged.reason ?? "exchange_failed" };
|
|
65
|
+
}
|
|
66
|
+
const issuedAt = new Date().toISOString();
|
|
67
|
+
writeCredentials({
|
|
68
|
+
installationId,
|
|
69
|
+
accessToken: exchanged.token.access_token,
|
|
70
|
+
tokenType: "Bearer",
|
|
71
|
+
scope: exchanged.token.scope,
|
|
72
|
+
issuedAt,
|
|
73
|
+
platform,
|
|
74
|
+
cliVersion: CLI_VERSION,
|
|
75
|
+
});
|
|
76
|
+
return { ok: true, authUrl };
|
|
77
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `sora-mcp auth logout` implementation.
|
|
3
|
+
*
|
|
4
|
+
* Order:
|
|
5
|
+
* 1. Read the credential file.
|
|
6
|
+
* 2. Call /api/oauth/workbuddy/revoke.
|
|
7
|
+
* 3. Only delete the local file if the revoke succeeded.
|
|
8
|
+
* 4. If the network is unreachable, keep the local file and exit non-zero
|
|
9
|
+
* so the user can retry revocation.
|
|
10
|
+
*/
|
|
11
|
+
import { readCredentials, deleteCredentials } from "./credentialWriter.js";
|
|
12
|
+
import { revokeWorkbuddyToken, defaultApiUrl } from "./exchange.js";
|
|
13
|
+
export async function runLogout() {
|
|
14
|
+
const creds = readCredentials();
|
|
15
|
+
if (!creds) {
|
|
16
|
+
return { ok: true, revoked: false, localDeleted: false };
|
|
17
|
+
}
|
|
18
|
+
const apiBaseUrl = defaultApiUrl();
|
|
19
|
+
const result = await revokeWorkbuddyToken({
|
|
20
|
+
apiBaseUrl,
|
|
21
|
+
accessToken: creds.accessToken,
|
|
22
|
+
installationId: creds.installationId,
|
|
23
|
+
});
|
|
24
|
+
if (!result.ok) {
|
|
25
|
+
return {
|
|
26
|
+
ok: false,
|
|
27
|
+
reason: result.reason ?? `http_${result.status}`,
|
|
28
|
+
revoked: false,
|
|
29
|
+
localDeleted: false,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
const localDeleted = deleteCredentials();
|
|
33
|
+
return { ok: true, revoked: true, localDeleted };
|
|
34
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loopback HTTP listener that captures the WorkBuddy OAuth callback.
|
|
3
|
+
*
|
|
4
|
+
* Per RFC 8252 §7.3 the CLI binds 127.0.0.1 to a random port, registers
|
|
5
|
+
* `/oauth/callback` and waits for a single GET with `?code=...&state=...`.
|
|
6
|
+
* Anything else is rejected with 400.
|
|
7
|
+
*/
|
|
8
|
+
import { createServer } from "node:http";
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
import { generateCodeVerifier, s256Challenge } from "./pkce.js";
|
|
11
|
+
async function pickRandomPort(hostname) {
|
|
12
|
+
return await new Promise((resolve, reject) => {
|
|
13
|
+
const probe = createServer();
|
|
14
|
+
probe.on("error", reject);
|
|
15
|
+
probe.listen(0, hostname, () => {
|
|
16
|
+
const addr = probe.address();
|
|
17
|
+
if (addr && typeof addr === "object") {
|
|
18
|
+
const port = addr.port;
|
|
19
|
+
probe.close(() => resolve(port));
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
probe.close(() => reject(new Error("no_port")));
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
function htmlResponse(res, status, body) {
|
|
28
|
+
res.statusCode = status;
|
|
29
|
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
30
|
+
res.setHeader("Cache-Control", "no-store");
|
|
31
|
+
res.end(body);
|
|
32
|
+
}
|
|
33
|
+
const SUCCESS_PAGE = `<!doctype html>
|
|
34
|
+
<html lang="zh-CN">
|
|
35
|
+
<head>
|
|
36
|
+
<meta charset="utf-8" />
|
|
37
|
+
<title>WorkBuddy 授权完成</title>
|
|
38
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
39
|
+
<style>
|
|
40
|
+
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", sans-serif; background: #0f1115; color: #f1f1f1; margin: 0; padding: 48px; }
|
|
41
|
+
.card { max-width: 480px; margin: 0 auto; background: #1a1d23; border: 1px solid #2a2d33; border-radius: 12px; padding: 32px; text-align: center; }
|
|
42
|
+
h1 { font-size: 20px; margin: 0 0 16px; }
|
|
43
|
+
p { color: #b8b8b8; font-size: 14px; line-height: 1.6; }
|
|
44
|
+
</style>
|
|
45
|
+
</head>
|
|
46
|
+
<body>
|
|
47
|
+
<div class="card">
|
|
48
|
+
<h1>WorkBuddy 授权完成</h1>
|
|
49
|
+
<p>您已成功将极睿视频账户连接到 WorkBuddy。</p>
|
|
50
|
+
<p>请返回 WorkBuddy 继续;可以关闭此页面。</p>
|
|
51
|
+
</div>
|
|
52
|
+
</body>
|
|
53
|
+
</html>`;
|
|
54
|
+
const ERROR_PAGE = (msg) => `<!doctype html>
|
|
55
|
+
<html lang="zh-CN">
|
|
56
|
+
<head>
|
|
57
|
+
<meta charset="utf-8" />
|
|
58
|
+
<title>WorkBuddy 授权失败</title>
|
|
59
|
+
</head>
|
|
60
|
+
<body style="font-family: -apple-system, BlinkMacSystemFont, sans-serif; padding: 48px;">
|
|
61
|
+
<h1>WorkBuddy 授权失败</h1>
|
|
62
|
+
<p>${msg}</p>
|
|
63
|
+
<p>请返回 WorkBuddy 重试。</p>
|
|
64
|
+
</body>
|
|
65
|
+
</html>`;
|
|
66
|
+
export async function bindLoopback(opts = {}) {
|
|
67
|
+
const hostname = opts.hostname ?? "127.0.0.1";
|
|
68
|
+
const port = opts.preferredPort ?? (await pickRandomPort(hostname));
|
|
69
|
+
const stateBytes = createHash("sha256").update(`${Date.now()}.${Math.random()}`).digest();
|
|
70
|
+
const state = stateBytes.toString("base64url").slice(0, 32);
|
|
71
|
+
const codeVerifier = generateCodeVerifier();
|
|
72
|
+
const codeChallenge = s256Challenge(codeVerifier);
|
|
73
|
+
let resolveCb = null;
|
|
74
|
+
let rejectCb = null;
|
|
75
|
+
let settled = false;
|
|
76
|
+
const callbackPromise = new Promise((resolve, reject) => {
|
|
77
|
+
resolveCb = resolve;
|
|
78
|
+
rejectCb = reject;
|
|
79
|
+
});
|
|
80
|
+
const handle = (req, res) => {
|
|
81
|
+
if (settled) {
|
|
82
|
+
htmlResponse(res, 410, ERROR_PAGE("回调已过期"));
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const url = req.url ?? "/";
|
|
86
|
+
if (!url.startsWith("/oauth/callback")) {
|
|
87
|
+
htmlResponse(res, 404, ERROR_PAGE("Not Found"));
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (req.method !== "GET") {
|
|
91
|
+
htmlResponse(res, 405, ERROR_PAGE("Method Not Allowed"));
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const parsed = new URL(url, `http://${hostname}:${port}`);
|
|
95
|
+
const code = parsed.searchParams.get("code");
|
|
96
|
+
const stateParam = parsed.searchParams.get("state");
|
|
97
|
+
const error = parsed.searchParams.get("error");
|
|
98
|
+
if (error) {
|
|
99
|
+
settled = true;
|
|
100
|
+
htmlResponse(res, 400, ERROR_PAGE(`授权服务器返回 ${error}`));
|
|
101
|
+
rejectCb?.(new Error(`oauth_error:${error}`));
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (!code || !stateParam) {
|
|
105
|
+
htmlResponse(res, 400, ERROR_PAGE("缺少 code 或 state"));
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (stateParam !== state) {
|
|
109
|
+
settled = true;
|
|
110
|
+
htmlResponse(res, 400, ERROR_PAGE("state 不匹配"));
|
|
111
|
+
rejectCb?.(new Error("state_mismatch"));
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
settled = true;
|
|
115
|
+
htmlResponse(res, 200, SUCCESS_PAGE);
|
|
116
|
+
resolveCb?.({ code, state: stateParam });
|
|
117
|
+
};
|
|
118
|
+
const server = createServer(handle);
|
|
119
|
+
await new Promise((resolve, reject) => {
|
|
120
|
+
server.once("error", reject);
|
|
121
|
+
server.listen(port, hostname, () => resolve());
|
|
122
|
+
});
|
|
123
|
+
const boundPort = server.address()?.port ?? port;
|
|
124
|
+
const close = () => new Promise((resolve) => {
|
|
125
|
+
server.close(() => resolve());
|
|
126
|
+
});
|
|
127
|
+
const awaitCallback = (timeoutMs) => new Promise((resolve, reject) => {
|
|
128
|
+
const timer = setTimeout(() => {
|
|
129
|
+
if (!settled) {
|
|
130
|
+
settled = true;
|
|
131
|
+
reject(new Error("loopback_timeout"));
|
|
132
|
+
}
|
|
133
|
+
}, timeoutMs);
|
|
134
|
+
callbackPromise
|
|
135
|
+
.then((v) => {
|
|
136
|
+
clearTimeout(timer);
|
|
137
|
+
resolve(v);
|
|
138
|
+
})
|
|
139
|
+
.catch((err) => {
|
|
140
|
+
clearTimeout(timer);
|
|
141
|
+
reject(err);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
return {
|
|
145
|
+
port: boundPort,
|
|
146
|
+
url: `http://${hostname}:${boundPort}/oauth/callback`,
|
|
147
|
+
state,
|
|
148
|
+
codeVerifier,
|
|
149
|
+
codeChallenge,
|
|
150
|
+
close,
|
|
151
|
+
awaitCallback,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Filesystem layout for WorkBuddy-managed credentials.
|
|
3
|
+
*
|
|
4
|
+
* Per plan §4: the CLI persists the credential file at
|
|
5
|
+
* ~/.workbuddy/connectors/infimind-video/auth/credentials.json
|
|
6
|
+
* with directory mode 0700 and file mode 0600.
|
|
7
|
+
*
|
|
8
|
+
* `~` is resolved to $HOME (or to a process.env override WORKBUDDY_HOME for
|
|
9
|
+
* testing). Tests can monkey-patch the resolver by setting WORKBUDDY_HOME.
|
|
10
|
+
*/
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
import { join, resolve } from "node:path";
|
|
13
|
+
export const WORKBUDDY_HOME_ENV = "WORKBUDDY_HOME";
|
|
14
|
+
export const CONNECTOR_ID = "infimind-video";
|
|
15
|
+
export const CREDENTIAL_FILENAME = "credentials.json";
|
|
16
|
+
export function workbuddyHome() {
|
|
17
|
+
const env = process.env[WORKBUDDY_HOME_ENV];
|
|
18
|
+
if (env && env.length > 0)
|
|
19
|
+
return resolve(env);
|
|
20
|
+
return join(homedir(), ".workbuddy");
|
|
21
|
+
}
|
|
22
|
+
export function credentialsDir() {
|
|
23
|
+
return join(workbuddyHome(), "connectors", CONNECTOR_ID, "auth");
|
|
24
|
+
}
|
|
25
|
+
export function credentialsFilePath() {
|
|
26
|
+
return join(credentialsDir(), CREDENTIAL_FILENAME);
|
|
27
|
+
}
|
|
28
|
+
export function installationIdFilePath() {
|
|
29
|
+
return join(credentialsDir(), "installation_id");
|
|
30
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PKCE S256 helpers (CLI side). Mirrors the server's checks exactly so the
|
|
3
|
+
* verifier we generate always produces a challenge the server accepts.
|
|
4
|
+
*/
|
|
5
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
6
|
+
export function generateCodeVerifier() {
|
|
7
|
+
return randomBytes(32).toString("base64url");
|
|
8
|
+
}
|
|
9
|
+
export function s256Challenge(verifier) {
|
|
10
|
+
return createHash("sha256").update(verifier, "utf8").digest("base64url");
|
|
11
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export const WORKBUDDY_TOOL_NAMES = [
|
|
2
|
+
"get_user_info",
|
|
3
|
+
"create_video_task",
|
|
4
|
+
"get_video_status",
|
|
5
|
+
"create_video_analysis_task",
|
|
6
|
+
"get_video_analysis_status",
|
|
7
|
+
];
|
|
8
|
+
export function isWorkbuddyTool(name) {
|
|
9
|
+
return WORKBUDDY_TOOL_NAMES.includes(name);
|
|
10
|
+
}
|
|
11
|
+
export function filterToolsForProfile(tools, profile) {
|
|
12
|
+
if (profile !== "workbuddy")
|
|
13
|
+
return tools;
|
|
14
|
+
return tools.filter((t) => isWorkbuddyTool(t.name));
|
|
15
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Redact WorkBuddy OAuth / token material from any log line.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors the server-side redaction (server/oauthWorkbuddy/redact.ts) so the
|
|
5
|
+
* CLI never leaks the same secret types the server protects.
|
|
6
|
+
*
|
|
7
|
+
* Replaced patterns:
|
|
8
|
+
* - `wb_[A-Za-z0-9_-]{20,}` (raw WorkBuddy token)
|
|
9
|
+
* - `Bearer [A-Za-z0-9._-]{8,}` (Authorization header value)
|
|
10
|
+
* - `code_verifier=[A-Za-z0-9._-]{20,}` (PKCE verifier)
|
|
11
|
+
* - `code=[A-Za-z0-9._-]{20,}` (authorization code, possibly in URL fragment)
|
|
12
|
+
*
|
|
13
|
+
* We deliberately do NOT match bare base64url sequences of 43+ characters:
|
|
14
|
+
* MCP stdio JSON-RPC messages (which flow through `process.stdout.write`)
|
|
15
|
+
* frequently contain taskId / video URL hashes that are 30-60 chars long.
|
|
16
|
+
* A blanket 43-char redact would corrupt the protocol. Instead we redact
|
|
17
|
+
* only the explicit secret-shaped substrings above; if a CLI command logs
|
|
18
|
+
* a raw PKCE verifier or WorkBuddy token outside of one of these patterns,
|
|
19
|
+
* that's a bug to fix at the caller (don't print secrets).
|
|
20
|
+
*/
|
|
21
|
+
const WORKBUDDY_TOKEN_RE = /wb_[A-Za-z0-9_-]{20,}/g;
|
|
22
|
+
const BEARER_RE = /Bearer\s+[A-Za-z0-9._-]{8,}/g;
|
|
23
|
+
const CODE_VERIFIER_RE = /code_verifier=[A-Za-z0-9._-]{20,}/g;
|
|
24
|
+
const CODE_PARAM_RE = /code=[A-Za-z0-9._-]{20,}/g;
|
|
25
|
+
const PLACEHOLDER = "${REDACTED}";
|
|
26
|
+
export function redactSecrets(input) {
|
|
27
|
+
if (typeof input !== "string" || input.length === 0)
|
|
28
|
+
return input;
|
|
29
|
+
let out = input;
|
|
30
|
+
out = out.replace(WORKBUDDY_TOKEN_RE, PLACEHOLDER);
|
|
31
|
+
out = out.replace(BEARER_RE, `Bearer ${PLACEHOLDER}`);
|
|
32
|
+
out = out.replace(CODE_VERIFIER_RE, `code_verifier=${PLACEHOLDER}`);
|
|
33
|
+
out = out.replace(CODE_PARAM_RE, `code=${PLACEHOLDER}`);
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Wrap `process.stderr.write` so log lines are redacted before they leave
|
|
38
|
+
* the process. `process.stdout.write` is left untouched because the MCP
|
|
39
|
+
* stdio transport sends JSON-RPC messages there; any blanket redact would
|
|
40
|
+
* corrupt the protocol.
|
|
41
|
+
*
|
|
42
|
+
* Call once at startup of any CLI subcommand that emits log lines
|
|
43
|
+
* (auth login, auth logout, profile notes, error paths). The `serve`
|
|
44
|
+
* subcommand is excluded — see mcp-server/src/index.ts.
|
|
45
|
+
*/
|
|
46
|
+
export function installRedactingWriters() {
|
|
47
|
+
const origStderr = process.stderr.write.bind(process.stderr);
|
|
48
|
+
const wrap = (orig) => {
|
|
49
|
+
return ((chunk, ...args) => {
|
|
50
|
+
if (typeof chunk === "string") {
|
|
51
|
+
return orig(redactSecrets(chunk), ...args);
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
const decoded = Buffer.from(chunk).toString("utf8");
|
|
55
|
+
return orig(redactSecrets(decoded), ...args);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return orig(chunk, ...args);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
};
|
|
62
|
+
process.stderr.write = wrap(origStderr);
|
|
63
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `sora-mcp auth status --json` implementation.
|
|
3
|
+
*
|
|
4
|
+
* Reports `{"authenticated": true|false}`. We deliberately do not contact the
|
|
5
|
+
* server to avoid leaking the bearer token in headers; instead, the presence
|
|
6
|
+
* of a valid credential file (mode 0600) is enough for a "true" verdict.
|
|
7
|
+
*
|
|
8
|
+
* If the network is reachable, an offline status will still report
|
|
9
|
+
* `{"authenticated": false}` (fail-closed) per plan §3.
|
|
10
|
+
*/
|
|
11
|
+
import { existsSync, statSync } from "node:fs";
|
|
12
|
+
import { credentialsFilePath } from "./paths.js";
|
|
13
|
+
export function runStatus() {
|
|
14
|
+
const path = credentialsFilePath();
|
|
15
|
+
if (!existsSync(path)) {
|
|
16
|
+
return { authenticated: false, reason: "no_credentials", filePath: path };
|
|
17
|
+
}
|
|
18
|
+
try {
|
|
19
|
+
const st = statSync(path);
|
|
20
|
+
if (process.platform !== "win32") {
|
|
21
|
+
const mode = st.mode & 0o777;
|
|
22
|
+
if (mode & 0o077) {
|
|
23
|
+
return { authenticated: false, reason: "insecure_permissions", filePath: path };
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return { authenticated: true, filePath: path };
|
|
27
|
+
}
|
|
28
|
+
catch (err) {
|
|
29
|
+
return { authenticated: false, reason: "stat_failed", filePath: path };
|
|
30
|
+
}
|
|
31
|
+
}
|