trakt-tools 1.11.1 → 1.11.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/README.md +17 -1
- package/dist/index.js +121 -48
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/skills/trakt/SKILL.md +11 -0
- package/src/index.ts +139 -63
- package/src/safe-shell.ts +4 -1
package/README.md
CHANGED
|
@@ -36,9 +36,25 @@ Or set `TRAKT_CLI_PATH` environment variable, or configure `cliPath` in plugin s
|
|
|
36
36
|
| `trakt_history_add` | Mark movies/shows as watched |
|
|
37
37
|
| `trakt_watchlist` | View watchlist |
|
|
38
38
|
| `trakt_progress` | Show watch progress for TV shows |
|
|
39
|
-
| `trakt_auth` |
|
|
39
|
+
| `trakt_auth` | Start device authorization, then poll its session after approval |
|
|
40
40
|
| `trakt_sync` | Opt-in: import from streaming services (only when `syncEnabled`) |
|
|
41
41
|
|
|
42
|
+
## Authentication
|
|
43
|
+
|
|
44
|
+
Use `trakt_auth` with `{ "action": "start" }` (or no parameters). Show the
|
|
45
|
+
returned `verification_url` and `user_code` immediately. After the user approves,
|
|
46
|
+
call it with `{ "action": "poll", "session": "<returned session>" }`, waiting at
|
|
47
|
+
least the returned `interval` between pending responses. Stop when the status is
|
|
48
|
+
`authenticated`, the session expires, or an error is returned. Keep the session
|
|
49
|
+
handle within this authorization flow.
|
|
50
|
+
|
|
51
|
+
This requires a matching `trakt-cli` with the `auth --session` option. The plugin
|
|
52
|
+
checks that capability before starting authentication. On POSIX systems, update
|
|
53
|
+
an older CLI or run `trakt-cli auth` locally for interactive setup. On Windows,
|
|
54
|
+
complete initial authentication with `trakt-cli auth` locally; nonblocking private
|
|
55
|
+
sessions are currently supported on POSIX systems. Configured client credentials
|
|
56
|
+
are passed in a separate child environment rather than command-line arguments.
|
|
57
|
+
|
|
42
58
|
## License
|
|
43
59
|
|
|
44
60
|
MIT
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
2
|
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
|
3
3
|
import { Type } from "@sinclair/typebox";
|
|
4
|
+
import { Value } from "@sinclair/typebox/value";
|
|
4
5
|
import { existsSync } from "fs";
|
|
5
6
|
import { homedir } from "os";
|
|
6
7
|
import { join } from "path";
|
|
@@ -12,6 +13,7 @@ var _runFileAsync = promisify(_runFile);
|
|
|
12
13
|
async function runCli(cli, args, opts = {}) {
|
|
13
14
|
const { stdout, stderr } = await _runFileAsync(cli, args, {
|
|
14
15
|
encoding: "utf8",
|
|
16
|
+
env: opts.env,
|
|
15
17
|
timeout: opts.timeout ?? 12e4,
|
|
16
18
|
maxBuffer: opts.maxBuffer ?? 4 * 1024 * 1024
|
|
17
19
|
});
|
|
@@ -185,8 +187,11 @@ var TOOLS = [
|
|
|
185
187
|
{
|
|
186
188
|
name: "trakt_auth",
|
|
187
189
|
command: "auth",
|
|
188
|
-
description: "Set up Trakt.tv authentication.
|
|
189
|
-
parameters: Type.Object({
|
|
190
|
+
description: "Set up Trakt.tv authentication without waiting for approval. Start returns verification_url, user_code, session, expiry and polling interval. Show the instructions immediately, then poll that session no faster than the returned interval after the user approves.",
|
|
191
|
+
parameters: Type.Object({
|
|
192
|
+
action: Type.Optional(Type.Union([Type.Literal("start"), Type.Literal("poll")])),
|
|
193
|
+
session: Type.Optional(Type.String({ pattern: "^[0-9a-f]{32}$" }))
|
|
194
|
+
})
|
|
190
195
|
}
|
|
191
196
|
];
|
|
192
197
|
function toolResult(text) {
|
|
@@ -214,58 +219,111 @@ function resolveCliPath(config) {
|
|
|
214
219
|
function isAuthConfigured() {
|
|
215
220
|
return existsSync(join(homedir(), ".trakt.yaml"));
|
|
216
221
|
}
|
|
217
|
-
function
|
|
222
|
+
function closedParameters(tool) {
|
|
223
|
+
return Type.Object(tool.parameters.properties, { additionalProperties: false });
|
|
224
|
+
}
|
|
225
|
+
function validParameters(schema, params) {
|
|
226
|
+
try {
|
|
227
|
+
return Value.Check(schema, params);
|
|
228
|
+
} catch {
|
|
229
|
+
return false;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
function validAuthParameters(params) {
|
|
233
|
+
const action = params.action ?? "start";
|
|
234
|
+
return action === "poll" ? typeof params.session === "string" : params.session === void 0;
|
|
235
|
+
}
|
|
236
|
+
function buildCliArgs(command, subcommand, params) {
|
|
218
237
|
const args = [command];
|
|
219
238
|
if (command === "auth") {
|
|
220
|
-
|
|
221
|
-
if (
|
|
239
|
+
args.push("--json");
|
|
240
|
+
if (params.action === "poll") args.push("--session", params.session);
|
|
222
241
|
return args;
|
|
223
242
|
}
|
|
224
243
|
if (subcommand) args.push(subcommand);
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
if (command === "history" && subcommand === "add") {
|
|
229
|
-
const titles = params.titles;
|
|
230
|
-
if (titles) {
|
|
231
|
-
if (params.type) args.push("--type", String(params.type));
|
|
232
|
-
if (params.watched_at) args.push("--watched-at", String(params.watched_at));
|
|
233
|
-
for (const title of titles) {
|
|
234
|
-
args.push(title);
|
|
235
|
-
}
|
|
236
|
-
args.push("--json");
|
|
237
|
-
return args;
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
const titleArrayCommands = [
|
|
241
|
-
["watchlist", "add"],
|
|
242
|
-
["watchlist", "remove"],
|
|
243
|
-
["history", "remove"]
|
|
244
|
-
];
|
|
245
|
-
if (titleArrayCommands.some(([c, s]) => c === command && s === subcommand)) {
|
|
246
|
-
const titles = params.titles;
|
|
247
|
-
if (titles) {
|
|
248
|
-
if (params.type) args.push("--type", String(params.type));
|
|
249
|
-
for (const title of titles) {
|
|
250
|
-
args.push(title);
|
|
251
|
-
}
|
|
252
|
-
args.push("--json");
|
|
253
|
-
return args;
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
const skipKeys = /* @__PURE__ */ new Set(["query", "titles"]);
|
|
244
|
+
const positionals = [];
|
|
245
|
+
if (command === "search") positionals.push(params.query);
|
|
246
|
+
if (Array.isArray(params.titles)) positionals.push(...params.titles);
|
|
257
247
|
for (const [key, value] of Object.entries(params)) {
|
|
258
|
-
if (
|
|
248
|
+
if (key === "query" || key === "titles" || value === void 0 || value === false) continue;
|
|
259
249
|
const flag = `--${key.replace(/_/g, "-")}`;
|
|
260
|
-
if (
|
|
261
|
-
|
|
262
|
-
} else {
|
|
263
|
-
args.push(flag, String(value));
|
|
264
|
-
}
|
|
250
|
+
if (value === true) args.push(flag);
|
|
251
|
+
else args.push(flag, String(value));
|
|
265
252
|
}
|
|
266
253
|
args.push("--json");
|
|
254
|
+
if (positionals.length) args.push("--", ...positionals);
|
|
267
255
|
return args;
|
|
268
256
|
}
|
|
257
|
+
function authEnvironment(config) {
|
|
258
|
+
return {
|
|
259
|
+
...process.env,
|
|
260
|
+
...config?.clientId ? { TRAKT_CLIENT_ID: config.clientId } : {},
|
|
261
|
+
...config?.clientSecret ? { TRAKT_CLIENT_SECRET: config.clientSecret } : {}
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
async function supportsAuthSessions(cliPath) {
|
|
265
|
+
const env = {};
|
|
266
|
+
for (const key of ["PATH", "SystemRoot", "WINDIR", "ComSpec"]) {
|
|
267
|
+
if (process.env[key] !== void 0) env[key] = process.env[key];
|
|
268
|
+
}
|
|
269
|
+
try {
|
|
270
|
+
const { stdout } = await runCli(cliPath, ["auth", "--help"], {
|
|
271
|
+
timeout: 5e3,
|
|
272
|
+
maxBuffer: 256 * 1024,
|
|
273
|
+
env
|
|
274
|
+
});
|
|
275
|
+
return /^\s+--session(?:\s|=)/m.test(stdout);
|
|
276
|
+
} catch {
|
|
277
|
+
return false;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
var AUTH_RESPONSE = Type.Object({
|
|
281
|
+
success: Type.Literal(true),
|
|
282
|
+
status: Type.Union([Type.Literal("pending"), Type.Literal("authenticated")]),
|
|
283
|
+
session: Type.Optional(Type.String({ pattern: "^[0-9a-f]{32}$" })),
|
|
284
|
+
verification_url: Type.Optional(Type.String({ minLength: 1, maxLength: 2048 })),
|
|
285
|
+
user_code: Type.Optional(Type.String({ pattern: "^[A-Za-z0-9-]{1,128}$" })),
|
|
286
|
+
expires_in: Type.Optional(Type.Integer({ minimum: 0 })),
|
|
287
|
+
interval: Type.Optional(Type.Integer({ minimum: 1 }))
|
|
288
|
+
}, { additionalProperties: false });
|
|
289
|
+
var AUTH_VERIFICATION_ORIGINS = /* @__PURE__ */ new Set([
|
|
290
|
+
"https://trakt.tv",
|
|
291
|
+
"https://www.trakt.tv",
|
|
292
|
+
"https://trakt.tv:443",
|
|
293
|
+
"https://www.trakt.tv:443"
|
|
294
|
+
]);
|
|
295
|
+
function validVerificationURL(value, clientSecret) {
|
|
296
|
+
const pathStart = value.indexOf("/", "https://".length);
|
|
297
|
+
const origin = pathStart === -1 ? value : value.slice(0, pathStart);
|
|
298
|
+
if (!AUTH_VERIFICATION_ORIGINS.has(origin) || value.includes("?") || value.includes("#") || [...value].some((char) => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127)) return false;
|
|
299
|
+
const url = new URL(value);
|
|
300
|
+
if (url.protocol !== "https:" || url.username || url.password || url.hostname !== "trakt.tv" && url.hostname !== "www.trakt.tv" || url.port !== "" && url.port !== "443") return false;
|
|
301
|
+
return !clientSecret || !decodeURIComponent(url.pathname).includes(clientSecret);
|
|
302
|
+
}
|
|
303
|
+
function parseAuthResponse(stdout, params, clientSecret) {
|
|
304
|
+
const result = JSON.parse(stdout);
|
|
305
|
+
if (!Value.Check(AUTH_RESPONSE, result)) throw new Error("Invalid auth response");
|
|
306
|
+
if (clientSecret && Object.values(result).some((value) => typeof value === "string" && value.includes(clientSecret))) {
|
|
307
|
+
throw new Error("Invalid auth response");
|
|
308
|
+
}
|
|
309
|
+
const action = params.action ?? "start";
|
|
310
|
+
if (result.status === "authenticated") {
|
|
311
|
+
if (action !== "poll" || Object.keys(result).some((key) => key !== "success" && key !== "status")) {
|
|
312
|
+
throw new Error("Invalid auth response");
|
|
313
|
+
}
|
|
314
|
+
return result;
|
|
315
|
+
}
|
|
316
|
+
if (!result.session || result.expires_in === void 0 || result.interval === void 0) {
|
|
317
|
+
throw new Error("Invalid auth response");
|
|
318
|
+
}
|
|
319
|
+
if (action === "start") {
|
|
320
|
+
if (!result.verification_url || !result.user_code) throw new Error("Invalid auth response");
|
|
321
|
+
if (!validVerificationURL(result.verification_url, clientSecret)) throw new Error("Invalid auth response");
|
|
322
|
+
} else if (result.session !== params.session || result.verification_url !== void 0 || result.user_code !== void 0) {
|
|
323
|
+
throw new Error("Invalid auth response");
|
|
324
|
+
}
|
|
325
|
+
return result;
|
|
326
|
+
}
|
|
269
327
|
var index_default = definePluginEntry({
|
|
270
328
|
id: "trakt-tools",
|
|
271
329
|
name: "Trakt",
|
|
@@ -283,7 +341,7 @@ var index_default = definePluginEntry({
|
|
|
283
341
|
name: tool.name,
|
|
284
342
|
label: tool.name,
|
|
285
343
|
description: tool.description,
|
|
286
|
-
parameters: tool
|
|
344
|
+
parameters: closedParameters(tool),
|
|
287
345
|
async execute() {
|
|
288
346
|
return toolResult(
|
|
289
347
|
JSON.stringify({ success: false, error: errorMessage }, null, 2)
|
|
@@ -294,18 +352,28 @@ var index_default = definePluginEntry({
|
|
|
294
352
|
return;
|
|
295
353
|
}
|
|
296
354
|
for (const tool of tools) {
|
|
355
|
+
const parameters = closedParameters(tool);
|
|
297
356
|
api.registerTool({
|
|
298
357
|
name: tool.name,
|
|
299
358
|
label: tool.name,
|
|
300
359
|
description: tool.description,
|
|
301
|
-
parameters
|
|
360
|
+
parameters,
|
|
302
361
|
async execute(_id, params) {
|
|
362
|
+
if (!validParameters(parameters, params) || tool.command === "auth" && !validAuthParameters(params)) {
|
|
363
|
+
return toolResult(JSON.stringify({ success: false, error: "Invalid parameters for this Trakt tool." }));
|
|
364
|
+
}
|
|
365
|
+
if (tool.command === "auth" && !await supportsAuthSessions(cliPath)) {
|
|
366
|
+
return toolResult(JSON.stringify({
|
|
367
|
+
success: false,
|
|
368
|
+
error: "This trakt-cli does not support nonblocking authentication. Update trakt-cli, or run trakt-cli auth locally."
|
|
369
|
+
}));
|
|
370
|
+
}
|
|
303
371
|
if (tool.command !== "auth" && !isAuthConfigured()) {
|
|
304
372
|
return toolResult(
|
|
305
373
|
JSON.stringify(
|
|
306
374
|
{
|
|
307
375
|
success: false,
|
|
308
|
-
error: "Trakt auth not configured.
|
|
376
|
+
error: "Trakt auth not configured. Start trakt_auth, show its verification instructions, then poll its session. Or run trakt-cli auth locally."
|
|
309
377
|
},
|
|
310
378
|
null,
|
|
311
379
|
2
|
|
@@ -313,12 +381,17 @@ var index_default = definePluginEntry({
|
|
|
313
381
|
);
|
|
314
382
|
}
|
|
315
383
|
try {
|
|
316
|
-
const args = buildCliArgs(tool.command, tool.subcommand, params
|
|
384
|
+
const args = buildCliArgs(tool.command, tool.subcommand, params);
|
|
385
|
+
const env = tool.command === "auth" ? authEnvironment(config) : void 0;
|
|
317
386
|
const { stdout } = await runCli(cliPath, args, {
|
|
318
387
|
// sync walks several services plus per-show history lookups.
|
|
319
388
|
timeout: tool.command === "sync" ? 6e5 : 12e4,
|
|
320
|
-
maxBuffer: 4 * 1024 * 1024
|
|
389
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
390
|
+
env
|
|
321
391
|
});
|
|
392
|
+
if (tool.command === "auth") {
|
|
393
|
+
return toolResult(JSON.stringify(parseAuthResponse(stdout, params, env?.TRAKT_CLIENT_SECRET), null, 2));
|
|
394
|
+
}
|
|
322
395
|
let result;
|
|
323
396
|
try {
|
|
324
397
|
result = JSON.parse(stdout);
|
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
package/skills/trakt/SKILL.md
CHANGED
|
@@ -19,6 +19,17 @@ View watch history, watchlist, progress, search, and mark items as watched on Tr
|
|
|
19
19
|
|
|
20
20
|
All commands support `--json` for machine-readable output. **Always use `--json` for data processing.**
|
|
21
21
|
|
|
22
|
+
## Authentication through the tool
|
|
23
|
+
|
|
24
|
+
Call `trakt_auth` with `action: "start"` for initial setup. Present the returned
|
|
25
|
+
verification URL and user code immediately so the user can approve. Then call
|
|
26
|
+
`trakt_auth` with `action: "poll"` and the exact returned session handle. Wait at
|
|
27
|
+
least the returned interval before each subsequent poll. Stop on authenticated
|
|
28
|
+
status, expiry or an error; do not start repeated new sessions while approval is
|
|
29
|
+
pending. On Windows, ask the user to complete initial authentication with
|
|
30
|
+
`trakt-cli auth` locally. On POSIX systems, the matching CLI must support
|
|
31
|
+
`auth --session`; update an older CLI or use local interactive authentication.
|
|
32
|
+
|
|
22
33
|
## Commands
|
|
23
34
|
|
|
24
35
|
### Progress (In-Progress Shows)
|
package/src/index.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { definePluginEntry } from 'openclaw/plugin-sdk/plugin-entry';
|
|
9
9
|
import { Type } from '@sinclair/typebox';
|
|
10
|
+
import { Value } from '@sinclair/typebox/value';
|
|
10
11
|
import { existsSync } from 'fs';
|
|
11
12
|
import { homedir } from 'os';
|
|
12
13
|
import { join } from 'path';
|
|
@@ -199,8 +200,11 @@ const TOOLS: ToolDef[] = [
|
|
|
199
200
|
name: 'trakt_auth',
|
|
200
201
|
command: 'auth',
|
|
201
202
|
description:
|
|
202
|
-
'Set up Trakt.tv authentication.
|
|
203
|
-
parameters: Type.Object({
|
|
203
|
+
'Set up Trakt.tv authentication without waiting for approval. Start returns verification_url, user_code, session, expiry and polling interval. Show the instructions immediately, then poll that session no faster than the returned interval after the user approves.',
|
|
204
|
+
parameters: Type.Object({
|
|
205
|
+
action: Type.Optional(Type.Union([Type.Literal('start'), Type.Literal('poll')])),
|
|
206
|
+
session: Type.Optional(Type.String({ pattern: '^[0-9a-f]{32}$' })),
|
|
207
|
+
}),
|
|
204
208
|
},
|
|
205
209
|
];
|
|
206
210
|
|
|
@@ -243,76 +247,133 @@ function isAuthConfigured(): boolean {
|
|
|
243
247
|
return existsSync(join(homedir(), '.trakt.yaml'));
|
|
244
248
|
}
|
|
245
249
|
|
|
246
|
-
/**
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
+
/** Keep the tool handler and advertised schema aligned on every invocation path. */
|
|
251
|
+
function closedParameters(tool: ToolDef): ToolDef['parameters'] {
|
|
252
|
+
return Type.Object(tool.parameters.properties, { additionalProperties: false });
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function validParameters(schema: ToolDef['parameters'], params: unknown): params is Record<string, unknown> {
|
|
256
|
+
try {
|
|
257
|
+
return Value.Check(schema, params);
|
|
258
|
+
} catch {
|
|
259
|
+
return false;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function validAuthParameters(params: Record<string, unknown>): boolean {
|
|
264
|
+
const action = params.action ?? 'start';
|
|
265
|
+
return action === 'poll' ? typeof params.session === 'string' : params.session === undefined;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Options precede --, so positional values cannot select CLI flags. */
|
|
250
269
|
function buildCliArgs(
|
|
251
270
|
command: string,
|
|
252
271
|
subcommand: string | undefined,
|
|
253
|
-
params: Record<string, unknown
|
|
254
|
-
config?: PluginConfig
|
|
272
|
+
params: Record<string, unknown>
|
|
255
273
|
): string[] {
|
|
256
|
-
const args
|
|
257
|
-
|
|
274
|
+
const args = [command];
|
|
258
275
|
if (command === 'auth') {
|
|
259
|
-
|
|
260
|
-
if (
|
|
276
|
+
args.push('--json');
|
|
277
|
+
if (params.action === 'poll') args.push('--session', params.session as string);
|
|
261
278
|
return args;
|
|
262
279
|
}
|
|
263
|
-
|
|
264
280
|
if (subcommand) args.push(subcommand);
|
|
265
281
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
282
|
+
const positionals: string[] = [];
|
|
283
|
+
if (command === 'search') positionals.push(params.query as string);
|
|
284
|
+
if (Array.isArray(params.titles)) positionals.push(...(params.titles as string[]));
|
|
269
285
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
for (const title of titles) {
|
|
276
|
-
args.push(title);
|
|
277
|
-
}
|
|
278
|
-
args.push('--json');
|
|
279
|
-
return args;
|
|
280
|
-
}
|
|
286
|
+
for (const [key, value] of Object.entries(params)) {
|
|
287
|
+
if (key === 'query' || key === 'titles' || value === undefined || value === false) continue;
|
|
288
|
+
const flag = `--${key.replace(/_/g, '-')}`;
|
|
289
|
+
if (value === true) args.push(flag);
|
|
290
|
+
else args.push(flag, String(value));
|
|
281
291
|
}
|
|
292
|
+
args.push('--json');
|
|
293
|
+
if (positionals.length) args.push('--', ...positionals);
|
|
294
|
+
return args;
|
|
295
|
+
}
|
|
282
296
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
297
|
+
/** Create a separate environment per call; never mutate process.env. */
|
|
298
|
+
function authEnvironment(config?: PluginConfig): NodeJS.ProcessEnv {
|
|
299
|
+
return {
|
|
300
|
+
...process.env,
|
|
301
|
+
...(config?.clientId ? { TRAKT_CLIENT_ID: config.clientId } : {}),
|
|
302
|
+
...(config?.clientSecret ? { TRAKT_CLIENT_SECRET: config.clientSecret } : {}),
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Old CLIs ignore --json during auth and would wait before returning a code. */
|
|
307
|
+
async function supportsAuthSessions(cliPath: string): Promise<boolean> {
|
|
308
|
+
const env: NodeJS.ProcessEnv = {};
|
|
309
|
+
for (const key of ['PATH', 'SystemRoot', 'WINDIR', 'ComSpec']) {
|
|
310
|
+
if (process.env[key] !== undefined) env[key] = process.env[key];
|
|
311
|
+
}
|
|
312
|
+
try {
|
|
313
|
+
const { stdout } = await runCli(cliPath, ['auth', '--help'], {
|
|
314
|
+
timeout: 5_000,
|
|
315
|
+
maxBuffer: 256 * 1024,
|
|
316
|
+
env,
|
|
317
|
+
});
|
|
318
|
+
return /^\s+--session(?:\s|=)/m.test(stdout);
|
|
319
|
+
} catch {
|
|
320
|
+
return false;
|
|
301
321
|
}
|
|
322
|
+
}
|
|
302
323
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
324
|
+
const AUTH_RESPONSE = Type.Object({
|
|
325
|
+
success: Type.Literal(true),
|
|
326
|
+
status: Type.Union([Type.Literal('pending'), Type.Literal('authenticated')]),
|
|
327
|
+
session: Type.Optional(Type.String({ pattern: '^[0-9a-f]{32}$' })),
|
|
328
|
+
verification_url: Type.Optional(Type.String({ minLength: 1, maxLength: 2048 })),
|
|
329
|
+
user_code: Type.Optional(Type.String({ pattern: '^[A-Za-z0-9-]{1,128}$' })),
|
|
330
|
+
expires_in: Type.Optional(Type.Integer({ minimum: 0 })),
|
|
331
|
+
interval: Type.Optional(Type.Integer({ minimum: 1 })),
|
|
332
|
+
}, { additionalProperties: false });
|
|
333
|
+
|
|
334
|
+
const AUTH_VERIFICATION_ORIGINS = new Set([
|
|
335
|
+
'https://trakt.tv', 'https://www.trakt.tv',
|
|
336
|
+
'https://trakt.tv:443', 'https://www.trakt.tv:443',
|
|
337
|
+
]);
|
|
338
|
+
|
|
339
|
+
/** Accept only Trakt's public verification destination and literal code data. */
|
|
340
|
+
function validVerificationURL(value: string, clientSecret?: string): boolean {
|
|
341
|
+
const pathStart = value.indexOf('/', 'https://'.length);
|
|
342
|
+
const origin = pathStart === -1 ? value : value.slice(0, pathStart);
|
|
343
|
+
if (!AUTH_VERIFICATION_ORIGINS.has(origin) || value.includes('?') || value.includes('#') ||
|
|
344
|
+
[...value].some(char => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127)) return false;
|
|
345
|
+
const url = new URL(value);
|
|
346
|
+
if (url.protocol !== 'https:' || url.username || url.password ||
|
|
347
|
+
(url.hostname !== 'trakt.tv' && url.hostname !== 'www.trakt.tv') ||
|
|
348
|
+
(url.port !== '' && url.port !== '443')) return false;
|
|
349
|
+
// Match the CLI's decoded-path reflection check, not only raw JSON text.
|
|
350
|
+
return !clientSecret || !decodeURIComponent(url.pathname).includes(clientSecret);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Return only the public device-flow protocol, never arbitrary auth stdout. */
|
|
354
|
+
function parseAuthResponse(stdout: string, params: Record<string, unknown>, clientSecret?: string): unknown {
|
|
355
|
+
const result: unknown = JSON.parse(stdout);
|
|
356
|
+
if (!Value.Check(AUTH_RESPONSE, result)) throw new Error('Invalid auth response');
|
|
357
|
+
if (clientSecret && Object.values(result).some(value => typeof value === 'string' && value.includes(clientSecret))) {
|
|
358
|
+
throw new Error('Invalid auth response');
|
|
359
|
+
}
|
|
360
|
+
const action = params.action ?? 'start';
|
|
361
|
+
if (result.status === 'authenticated') {
|
|
362
|
+
if (action !== 'poll' || Object.keys(result).some(key => key !== 'success' && key !== 'status')) {
|
|
363
|
+
throw new Error('Invalid auth response');
|
|
311
364
|
}
|
|
365
|
+
return result;
|
|
312
366
|
}
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
367
|
+
if (!result.session || result.expires_in === undefined || result.interval === undefined) {
|
|
368
|
+
throw new Error('Invalid auth response');
|
|
369
|
+
}
|
|
370
|
+
if (action === 'start') {
|
|
371
|
+
if (!result.verification_url || !result.user_code) throw new Error('Invalid auth response');
|
|
372
|
+
if (!validVerificationURL(result.verification_url, clientSecret)) throw new Error('Invalid auth response');
|
|
373
|
+
} else if (result.session !== params.session || result.verification_url !== undefined || result.user_code !== undefined) {
|
|
374
|
+
throw new Error('Invalid auth response');
|
|
375
|
+
}
|
|
376
|
+
return result;
|
|
316
377
|
}
|
|
317
378
|
|
|
318
379
|
export default definePluginEntry({
|
|
@@ -336,7 +397,7 @@ export default definePluginEntry({
|
|
|
336
397
|
name: tool.name,
|
|
337
398
|
label: tool.name,
|
|
338
399
|
description: tool.description,
|
|
339
|
-
parameters: tool
|
|
400
|
+
parameters: closedParameters(tool),
|
|
340
401
|
async execute() {
|
|
341
402
|
return toolResult(
|
|
342
403
|
JSON.stringify({ success: false, error: errorMessage }, null, 2)
|
|
@@ -348,20 +409,30 @@ export default definePluginEntry({
|
|
|
348
409
|
}
|
|
349
410
|
|
|
350
411
|
for (const tool of tools) {
|
|
412
|
+
const parameters = closedParameters(tool);
|
|
351
413
|
api.registerTool({
|
|
352
414
|
name: tool.name,
|
|
353
415
|
label: tool.name,
|
|
354
416
|
description: tool.description,
|
|
355
|
-
parameters
|
|
417
|
+
parameters,
|
|
356
418
|
|
|
357
419
|
async execute(_id: string, params: Record<string, unknown>) {
|
|
420
|
+
if (!validParameters(parameters, params) || (tool.command === 'auth' && !validAuthParameters(params))) {
|
|
421
|
+
return toolResult(JSON.stringify({ success: false, error: 'Invalid parameters for this Trakt tool.' }));
|
|
422
|
+
}
|
|
423
|
+
if (tool.command === 'auth' && !await supportsAuthSessions(cliPath)) {
|
|
424
|
+
return toolResult(JSON.stringify({
|
|
425
|
+
success: false,
|
|
426
|
+
error: 'This trakt-cli does not support nonblocking authentication. Update trakt-cli, or run trakt-cli auth locally.',
|
|
427
|
+
}));
|
|
428
|
+
}
|
|
358
429
|
if (tool.command !== 'auth' && !isAuthConfigured()) {
|
|
359
430
|
return toolResult(
|
|
360
431
|
JSON.stringify(
|
|
361
432
|
{
|
|
362
433
|
success: false,
|
|
363
434
|
error:
|
|
364
|
-
'Trakt auth not configured.
|
|
435
|
+
'Trakt auth not configured. Start trakt_auth, show its verification instructions, then poll its session. Or run trakt-cli auth locally.',
|
|
365
436
|
},
|
|
366
437
|
null,
|
|
367
438
|
2
|
|
@@ -370,7 +441,8 @@ export default definePluginEntry({
|
|
|
370
441
|
}
|
|
371
442
|
|
|
372
443
|
try {
|
|
373
|
-
const args = buildCliArgs(tool.command, tool.subcommand, params
|
|
444
|
+
const args = buildCliArgs(tool.command, tool.subcommand, params);
|
|
445
|
+
const env = tool.command === 'auth' ? authEnvironment(config) : undefined;
|
|
374
446
|
// 30s was too tight for `progress`, which iterates ~50 API calls
|
|
375
447
|
// (watchlist + watched + N× GetShowProgress) and empirically runs
|
|
376
448
|
// 35-45s for users with substantial watch history. 120s gives
|
|
@@ -382,8 +454,13 @@ export default definePluginEntry({
|
|
|
382
454
|
// sync walks several services plus per-show history lookups.
|
|
383
455
|
timeout: tool.command === 'sync' ? 600_000 : 120_000,
|
|
384
456
|
maxBuffer: 4 * 1024 * 1024,
|
|
457
|
+
env,
|
|
385
458
|
});
|
|
386
459
|
|
|
460
|
+
if (tool.command === 'auth') {
|
|
461
|
+
return toolResult(JSON.stringify(parseAuthResponse(stdout, params, env?.TRAKT_CLIENT_SECRET), null, 2));
|
|
462
|
+
}
|
|
463
|
+
|
|
387
464
|
let result: unknown;
|
|
388
465
|
try {
|
|
389
466
|
result = JSON.parse(stdout);
|
|
@@ -393,9 +470,8 @@ export default definePluginEntry({
|
|
|
393
470
|
|
|
394
471
|
return toolResult(JSON.stringify(result, null, 2));
|
|
395
472
|
} catch (error: unknown) {
|
|
396
|
-
//
|
|
397
|
-
//
|
|
398
|
-
// Never return raw authentication diagnostics to an agent.
|
|
473
|
+
// Authentication diagnostics can contain provider credentials.
|
|
474
|
+
// Never inspect or return raw authentication failures to an agent.
|
|
399
475
|
if (tool.command === 'auth') {
|
|
400
476
|
return toolResult(JSON.stringify({
|
|
401
477
|
success: false,
|
package/src/safe-shell.ts
CHANGED
|
@@ -21,9 +21,11 @@ const _runFileAsync = promisify(_runFile);
|
|
|
21
21
|
export interface RunOptions {
|
|
22
22
|
timeout?: number;
|
|
23
23
|
maxBuffer?: number;
|
|
24
|
+
/** Per-child environment. Omit to inherit the parent environment. */
|
|
25
|
+
env?: NodeJS.ProcessEnv;
|
|
24
26
|
}
|
|
25
27
|
|
|
26
|
-
/** Run a binary with
|
|
28
|
+
/** Run a binary with an argv array and return stdout, without a shell. */
|
|
27
29
|
export async function runCli(
|
|
28
30
|
cli: string,
|
|
29
31
|
args: string[],
|
|
@@ -31,6 +33,7 @@ export async function runCli(
|
|
|
31
33
|
): Promise<{ stdout: string; stderr: string }> {
|
|
32
34
|
const { stdout, stderr } = await _runFileAsync(cli, args, {
|
|
33
35
|
encoding: 'utf8',
|
|
36
|
+
env: opts.env,
|
|
34
37
|
timeout: opts.timeout ?? 120_000,
|
|
35
38
|
maxBuffer: opts.maxBuffer ?? 4 * 1024 * 1024,
|
|
36
39
|
});
|