requestshield 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 +96 -0
- package/package.json +31 -0
- package/skills/requestshield/SKILL.md +44 -0
- package/src/api-client.mjs +106 -0
- package/src/args.mjs +75 -0
- package/src/cli.mjs +40 -0
- package/src/commands/agent-setup.mjs +91 -0
- package/src/commands/keys-create.mjs +46 -0
- package/src/commands/signin.mjs +65 -0
- package/src/errors.mjs +14 -0
- package/src/main.mjs +25 -0
- package/src/session-store.mjs +91 -0
package/README.md
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# RequestShield CLI
|
|
2
|
+
|
|
3
|
+
Customer-facing CLI for signing in, rotating RequestShield application keys,
|
|
4
|
+
and installing the RequestShield skill for Codex.
|
|
5
|
+
|
|
6
|
+
## Requirements
|
|
7
|
+
|
|
8
|
+
- Node.js 22.13 or newer.
|
|
9
|
+
- A customer management API implementing the contract below.
|
|
10
|
+
|
|
11
|
+
The API defaults to `https://api.intellifend.ai`. For development, set
|
|
12
|
+
`REQUESTSHIELD_API_URL` to the management API origin.
|
|
13
|
+
|
|
14
|
+
## Commands
|
|
15
|
+
|
|
16
|
+
```console
|
|
17
|
+
requestshield signin
|
|
18
|
+
requestshield keys create
|
|
19
|
+
requestshield agent setup --agent codex
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
`signin` starts a device authorization flow and prints the verification URL and
|
|
23
|
+
code for the customer to open manually. The returned session is stored under the user's standard
|
|
24
|
+
application-data directory with permissions restricted to that user. CI may
|
|
25
|
+
instead provide `REQUESTSHIELD_ACCESS_TOKEN`; environment tokens are never
|
|
26
|
+
persisted.
|
|
27
|
+
|
|
28
|
+
`keys create` asks for confirmation, then atomically deactivates the previous
|
|
29
|
+
application credentials and creates a new `appKey` + `apiSecret`. The public
|
|
30
|
+
app key and the backend-only secret are printed after success. The secret is
|
|
31
|
+
not stored by the CLI and cannot be retrieved again.
|
|
32
|
+
|
|
33
|
+
`agent setup --agent codex` installs the bundled `requestshield` skill into
|
|
34
|
+
`$CODEX_HOME/skills`, or `~/.codex/skills` when `CODEX_HOME` is unset. Existing
|
|
35
|
+
content is preserved unless `--force` is supplied.
|
|
36
|
+
|
|
37
|
+
## Management API contract
|
|
38
|
+
|
|
39
|
+
### Start sign-in
|
|
40
|
+
|
|
41
|
+
`POST /v1/cli/signin`
|
|
42
|
+
|
|
43
|
+
```json
|
|
44
|
+
{
|
|
45
|
+
"client": "requestshield-cli"
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The response contains `deviceCode`, `userCode`, `verificationUri`,
|
|
50
|
+
`expiresIn`, and `interval`.
|
|
51
|
+
|
|
52
|
+
### Poll sign-in
|
|
53
|
+
|
|
54
|
+
`POST /v1/cli/signin/token` with `{ "deviceCode": "..." }`.
|
|
55
|
+
|
|
56
|
+
Pending responses use HTTP 400 with `code` set to `authorization_pending` or
|
|
57
|
+
`slow_down`. A successful response contains `accessToken` and optional account
|
|
58
|
+
metadata.
|
|
59
|
+
|
|
60
|
+
### Rotate keys
|
|
61
|
+
|
|
62
|
+
`POST /v1/cli/keys/rotate` with Bearer authentication and an empty JSON body.
|
|
63
|
+
The response contains `appKey` and one-time `apiSecret`.
|
|
64
|
+
|
|
65
|
+
## Development
|
|
66
|
+
|
|
67
|
+
```console
|
|
68
|
+
pnpm --filter requestshield lint
|
|
69
|
+
pnpm --filter requestshield typecheck
|
|
70
|
+
pnpm --filter requestshield test
|
|
71
|
+
pnpm --filter requestshield build
|
|
72
|
+
```
|
|
73
|
+
## Quick start
|
|
74
|
+
|
|
75
|
+
RequestShield requires Node.js 22.13 or newer.
|
|
76
|
+
|
|
77
|
+
Run without installing globally:
|
|
78
|
+
|
|
79
|
+
```console
|
|
80
|
+
npx requestshield --help
|
|
81
|
+
npx requestshield signin
|
|
82
|
+
npx requestshield keys create
|
|
83
|
+
npx requestshield agent setup --agent codex
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Pin a specific version for reproducible usage:
|
|
87
|
+
|
|
88
|
+
```console
|
|
89
|
+
npx requestshield@0.1.0 --help
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
To automatically accept npm's installation prompt:
|
|
93
|
+
|
|
94
|
+
```console
|
|
95
|
+
npx --yes requestshield@0.1.0 --help
|
|
96
|
+
```
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "requestshield",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Customer CLI for IntelliFend RequestShield.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"requestshield": "src/main.mjs"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"src/",
|
|
11
|
+
"skills/",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=22.13"
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"build": "node scripts/check-package.mjs",
|
|
19
|
+
"lint": "node scripts/check-package.mjs",
|
|
20
|
+
"test": "node --test",
|
|
21
|
+
"typecheck": "tsc -p jsconfig.json --noEmit",
|
|
22
|
+
"prepublishOnly": "npm run lint && npm run typecheck && npm test"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@types/node": "26.1.1",
|
|
26
|
+
"esbuild": "^0.28.1",
|
|
27
|
+
"postject": "1.0.0-alpha.6",
|
|
28
|
+
"typescript": "^6.0.3"
|
|
29
|
+
},
|
|
30
|
+
"license": "UNLICENSED"
|
|
31
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: requestshield
|
|
3
|
+
description: Set up or remove IntelliFend RequestShield protection in a customer application using the repository's Browser SDK and supported backend SDK. Use when the user asks an AI agent to integrate, configure, validate, or remove RequestShield protection.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# RequestShield Integration
|
|
7
|
+
|
|
8
|
+
Work only inside the customer's application repository. Do not access or modify
|
|
9
|
+
IntelliFend operator infrastructure, Cloudflare resources, or customer records.
|
|
10
|
+
|
|
11
|
+
Before editing, identify the frontend and backend frameworks and inspect the
|
|
12
|
+
relevant dependency and configuration files. Explain the files that will change.
|
|
13
|
+
Do not place the backend Secret Key in source, browser configuration, URLs, logs,
|
|
14
|
+
or committed files. The public App Key may be included in browser configuration.
|
|
15
|
+
|
|
16
|
+
## Set up protection
|
|
17
|
+
|
|
18
|
+
1. Obtain the public App Key, backend-only Secret Key, and Challenge Server URL
|
|
19
|
+
from the customer. If the Secret Key is unavailable, stop and ask the user to
|
|
20
|
+
put it in their backend secret manager; never ask them to paste it into chat.
|
|
21
|
+
2. Configure the Browser SDK with the App Key, Challenge Server URL, and exact
|
|
22
|
+
protected endpoint URLs. Query strings and fragments do not participate in
|
|
23
|
+
matching; path case and trailing slash do.
|
|
24
|
+
3. Configure the supported backend SDK with the same App Key and a secret-store
|
|
25
|
+
reference for the Secret Key. For Spring Boot 3 MVC, protect the actual
|
|
26
|
+
mutation method with `@RequestShieldProtected`.
|
|
27
|
+
4. For a new endpoint, start in `MONITOR`; move to `BLOCK` only after verifying
|
|
28
|
+
browser matching, backend outcomes, and CORS. Cross-origin customer APIs must
|
|
29
|
+
allow `X-IntelliFend-Token`.
|
|
30
|
+
5. Run the project's normal lint, build, and tests. Report any manual secret or
|
|
31
|
+
deployment step without performing external deployment unless requested.
|
|
32
|
+
|
|
33
|
+
Do not combine a manual `RequestShieldClient.verify(token)` call with annotation
|
|
34
|
+
enforcement on the same request: the token is single-use and would be consumed
|
|
35
|
+
twice.
|
|
36
|
+
|
|
37
|
+
## Remove protection
|
|
38
|
+
|
|
39
|
+
Remove only RequestShield-owned integration: browser initialization/protected
|
|
40
|
+
URL entries, backend annotation/configuration, and dependencies that have no
|
|
41
|
+
remaining callers. Preserve unrelated CORS, environment, build, and application
|
|
42
|
+
configuration. Never delete or rotate remote keys as part of source removal.
|
|
43
|
+
Run the project's normal validation after the change.
|
|
44
|
+
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import { CliError } from "./errors.mjs";
|
|
4
|
+
|
|
5
|
+
export class ManagementApiClient {
|
|
6
|
+
/** @param {{ baseUrl: string, fetchImpl?: typeof fetch }} options */
|
|
7
|
+
constructor({ baseUrl, fetchImpl = fetch }) {
|
|
8
|
+
let url;
|
|
9
|
+
try {
|
|
10
|
+
url = new URL(baseUrl);
|
|
11
|
+
} catch {
|
|
12
|
+
throw new CliError("REQUESTSHIELD_API_URL must be a valid URL", { exitCode: 2 });
|
|
13
|
+
}
|
|
14
|
+
if (url.protocol !== "https:" && !isLoopback(url)) {
|
|
15
|
+
throw new CliError("The management API must use HTTPS except on loopback", {
|
|
16
|
+
exitCode: 2,
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
this.baseUrl = url.href.replace(/\/$/, "");
|
|
20
|
+
this.fetchImpl = fetchImpl;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async startSignin() {
|
|
24
|
+
return this.#request("/v1/cli/signin", {
|
|
25
|
+
method: "POST",
|
|
26
|
+
body: JSON.stringify({ client: "requestshield-cli" }),
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** @param {string} deviceCode */
|
|
31
|
+
async pollSignin(deviceCode) {
|
|
32
|
+
return this.#request(
|
|
33
|
+
"/v1/cli/signin/token",
|
|
34
|
+
{ method: "POST", body: JSON.stringify({ deviceCode }) },
|
|
35
|
+
true,
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** @param {string} accessToken */
|
|
40
|
+
async rotateKeys(accessToken) {
|
|
41
|
+
return this.#request("/v1/cli/keys/rotate", {
|
|
42
|
+
method: "POST",
|
|
43
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
44
|
+
body: "{}",
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @param {string} path
|
|
50
|
+
* @param {RequestInit} init
|
|
51
|
+
* @param {boolean} [allowErrorBody]
|
|
52
|
+
*/
|
|
53
|
+
async #request(path, init, allowErrorBody = false) {
|
|
54
|
+
let response;
|
|
55
|
+
try {
|
|
56
|
+
response = await this.fetchImpl(`${this.baseUrl}${path}`, {
|
|
57
|
+
...init,
|
|
58
|
+
headers: { "Content-Type": "application/json", ...init.headers },
|
|
59
|
+
signal: AbortSignal.timeout(15_000),
|
|
60
|
+
});
|
|
61
|
+
} catch (error) {
|
|
62
|
+
throw new CliError(`Could not reach the RequestShield API: ${messageOf(error)}`, {
|
|
63
|
+
code: "NETWORK_ERROR",
|
|
64
|
+
exitCode: 7,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const text = await response.text();
|
|
69
|
+
let body = {};
|
|
70
|
+
if (text !== "") {
|
|
71
|
+
try {
|
|
72
|
+
body = JSON.parse(text);
|
|
73
|
+
} catch {
|
|
74
|
+
throw new CliError("The RequestShield API returned invalid JSON", {
|
|
75
|
+
code: "INVALID_RESPONSE",
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (!response.ok && !allowErrorBody) {
|
|
80
|
+
const detail = objectString(body, "message") ?? `HTTP ${response.status}`;
|
|
81
|
+
const exitCode = response.status === 401 || response.status === 403 ? 3 : 1;
|
|
82
|
+
throw new CliError(`RequestShield API error: ${detail}`, {
|
|
83
|
+
code: objectString(body, "code") ?? "API_ERROR",
|
|
84
|
+
exitCode,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
return { ok: response.ok, status: response.status, body };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** @param {URL} url */
|
|
92
|
+
function isLoopback(url) {
|
|
93
|
+
return url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]";
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** @param {unknown} value @param {string} property */
|
|
97
|
+
export function objectString(value, property) {
|
|
98
|
+
if (!value || typeof value !== "object") return undefined;
|
|
99
|
+
const found = Reflect.get(value, property);
|
|
100
|
+
return typeof found === "string" ? found : undefined;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** @param {unknown} error */
|
|
104
|
+
function messageOf(error) {
|
|
105
|
+
return error instanceof Error ? error.message : String(error);
|
|
106
|
+
}
|
package/src/args.mjs
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import { CliError } from "./errors.mjs";
|
|
4
|
+
|
|
5
|
+
const HELP = `RequestShield customer CLI
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
requestshield signin
|
|
9
|
+
requestshield keys create [--yes]
|
|
10
|
+
requestshield agent setup --agent codex [--force]
|
|
11
|
+
requestshield --help
|
|
12
|
+
requestshield --version`;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @typedef {{ command: "help", help: string }
|
|
16
|
+
* | { command: "version" }
|
|
17
|
+
* | { command: "signin" }
|
|
18
|
+
* | { command: "keys-create", yes: boolean }
|
|
19
|
+
* | { command: "agent-setup", agent: "codex", force: boolean }} ParsedArgs
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** @param {string[]} argv @returns {ParsedArgs} */
|
|
23
|
+
export function parseArgs(argv) {
|
|
24
|
+
if (argv.length === 0 || argv.includes("--help") || argv.includes("-h")) {
|
|
25
|
+
return { command: "help", help: HELP };
|
|
26
|
+
}
|
|
27
|
+
if (argv.length === 1 && (argv[0] === "--version" || argv[0] === "-V")) {
|
|
28
|
+
return { command: "version" };
|
|
29
|
+
}
|
|
30
|
+
if (argv[0] === "signin") {
|
|
31
|
+
assertOnly(argv.slice(1), new Set());
|
|
32
|
+
return { command: "signin" };
|
|
33
|
+
}
|
|
34
|
+
if (argv[0] === "keys" && argv[1] === "create") {
|
|
35
|
+
assertOnly(argv.slice(2), new Set(["--yes"]));
|
|
36
|
+
return { command: "keys-create", yes: argv.includes("--yes") };
|
|
37
|
+
}
|
|
38
|
+
if (argv[0] === "agent" && argv[1] === "setup") {
|
|
39
|
+
const rest = argv.slice(2);
|
|
40
|
+
/** @type {string | undefined} */
|
|
41
|
+
let agent;
|
|
42
|
+
let force = false;
|
|
43
|
+
for (let index = 0; index < rest.length; index++) {
|
|
44
|
+
const arg = rest[index];
|
|
45
|
+
if (arg === "--force") {
|
|
46
|
+
force = true;
|
|
47
|
+
} else if (arg === "--agent") {
|
|
48
|
+
agent = rest[++index];
|
|
49
|
+
if (!agent || agent.startsWith("--")) {
|
|
50
|
+
throw new CliError("--agent needs a value", { exitCode: 2 });
|
|
51
|
+
}
|
|
52
|
+
} else {
|
|
53
|
+
throw new CliError(`Unknown option: ${arg}`, { exitCode: 2 });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (agent !== "codex") {
|
|
57
|
+
throw new CliError("--agent codex is required; no other agent is supported", {
|
|
58
|
+
exitCode: 2,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
return { command: "agent-setup", agent: "codex", force };
|
|
62
|
+
}
|
|
63
|
+
throw new CliError(`Unknown command.\n\n${HELP}`, { exitCode: 2 });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** @param {string[]} args @param {Set<string>} allowed */
|
|
67
|
+
function assertOnly(args, allowed) {
|
|
68
|
+
for (const arg of args) {
|
|
69
|
+
if (!allowed.has(arg)) {
|
|
70
|
+
throw new CliError(`Unknown option: ${arg}`, { exitCode: 2 });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export { HELP };
|
package/src/cli.mjs
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// @ts-check
|
|
3
|
+
|
|
4
|
+
import { parseArgs } from "./args.mjs";
|
|
5
|
+
import { ManagementApiClient } from "./api-client.mjs";
|
|
6
|
+
import { SessionStore } from "./session-store.mjs";
|
|
7
|
+
import { signin } from "./commands/signin.mjs";
|
|
8
|
+
import { createKeys } from "./commands/keys-create.mjs";
|
|
9
|
+
import { setupAgent } from "./commands/agent-setup.mjs";
|
|
10
|
+
import packageJson from "../package.json" with { type: "json" };
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @param {string[]} argv
|
|
14
|
+
* @param {{ log?: (message: string) => void, api?: ManagementApiClient, sessions?: SessionStore, env?: NodeJS.ProcessEnv, homeDir?: string, sourceDir?: string, wait?: (milliseconds: number) => Promise<unknown>, confirm?: () => Promise<boolean> }} [deps]
|
|
15
|
+
*/
|
|
16
|
+
export async function run(argv, deps = {}) {
|
|
17
|
+
const parsed = parseArgs(argv);
|
|
18
|
+
const log = deps.log ?? ((message) => console.log(message));
|
|
19
|
+
if (parsed.command === "help") return log(parsed.help);
|
|
20
|
+
if (parsed.command === "version") return log(packageJson.version);
|
|
21
|
+
|
|
22
|
+
if (parsed.command === "agent-setup") {
|
|
23
|
+
return setupAgent(parsed, { log, ...deps });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const env = deps.env ?? process.env;
|
|
27
|
+
|
|
28
|
+
const api = deps.api ?? new ManagementApiClient({
|
|
29
|
+
baseUrl:
|
|
30
|
+
env.REQUESTSHIELD_API_URL ??
|
|
31
|
+
"https://api.intellifend.ai",
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const sessions = deps.sessions ?? new SessionStore({
|
|
35
|
+
env,
|
|
36
|
+
homeDir: deps.homeDir,
|
|
37
|
+
});
|
|
38
|
+
if (parsed.command === "signin") return signin({ api, sessions, log, ...deps });
|
|
39
|
+
if (parsed.command === "keys-create") return createKeys(parsed, { api, sessions, log, ...deps });
|
|
40
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import { cp, lstat, mkdir, rename, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { randomUUID } from "node:crypto";
|
|
7
|
+
import { CliError } from "../errors.mjs";
|
|
8
|
+
import { getAsset, isSea } from "node:sea";
|
|
9
|
+
|
|
10
|
+
const skillAsset = "requestshield-skill.md";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @param {{ agent: string, force: boolean }} options
|
|
14
|
+
* @param {{ env?: NodeJS.ProcessEnv, homeDir?: string, log: (message: string) => void, sourceDir?: string }} deps
|
|
15
|
+
*/
|
|
16
|
+
export async function setupAgent(options, deps) {
|
|
17
|
+
if (options.agent !== "codex") {
|
|
18
|
+
throw new CliError(`Unsupported agent: ${options.agent}`, { exitCode: 2 });
|
|
19
|
+
}
|
|
20
|
+
const env = deps.env ?? process.env;
|
|
21
|
+
const codexHome = env.CODEX_HOME || path.join(deps.homeDir ?? os.homedir(), ".codex");
|
|
22
|
+
const skillsRoot = path.join(codexHome, "skills");
|
|
23
|
+
const destination = path.join(skillsRoot, "requestshield");
|
|
24
|
+
await mkdir(skillsRoot, { recursive: true });
|
|
25
|
+
|
|
26
|
+
const exists = await pathExists(destination);
|
|
27
|
+
if (exists && !options.force) {
|
|
28
|
+
throw new CliError(`RequestShield skill already exists at ${destination}; use --force to replace it`, {
|
|
29
|
+
code: "SKILL_EXISTS",
|
|
30
|
+
exitCode: 2,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const staging = path.join(skillsRoot, `.requestshield-${randomUUID()}.tmp`);
|
|
35
|
+
const backup = `${destination}.${randomUUID()}.backup`;
|
|
36
|
+
try {
|
|
37
|
+
if (deps.sourceDir) {
|
|
38
|
+
// Dùng trong test hoặc khi caller chỉ định source.
|
|
39
|
+
await cp(deps.sourceDir, staging, {
|
|
40
|
+
recursive: true,
|
|
41
|
+
errorOnExist: true,
|
|
42
|
+
});
|
|
43
|
+
} else if (isSea()) {
|
|
44
|
+
// SEA không có thư mục source trên disk, đọc skill đã nhúng.
|
|
45
|
+
await mkdir(staging);
|
|
46
|
+
|
|
47
|
+
await writeFile(
|
|
48
|
+
path.join(staging, "SKILL.md"),
|
|
49
|
+
Buffer.from(getAsset(skillAsset)),
|
|
50
|
+
);
|
|
51
|
+
} else {
|
|
52
|
+
// Chạy từ source/npm hoặc chạy bundle CJS trên disk.
|
|
53
|
+
//
|
|
54
|
+
// src/main.mjs -> ../skills/requestshield
|
|
55
|
+
// dist/requestshield.cjs -> ../skills/requestshield
|
|
56
|
+
const executableDir = path.dirname(path.resolve(process.argv[1]));
|
|
57
|
+
const source = path.resolve(
|
|
58
|
+
executableDir,
|
|
59
|
+
"../skills/requestshield",
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
await cp(source, staging, {
|
|
63
|
+
recursive: true,
|
|
64
|
+
errorOnExist: true,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
if (exists) await rename(destination, backup);
|
|
68
|
+
await rename(staging, destination);
|
|
69
|
+
if (exists) await rm(backup, { recursive: true, force: true });
|
|
70
|
+
} catch (error) {
|
|
71
|
+
await rm(staging, { recursive: true, force: true });
|
|
72
|
+
if (await pathExists(backup)) {
|
|
73
|
+
await rm(destination, { recursive: true, force: true });
|
|
74
|
+
await rename(backup, destination);
|
|
75
|
+
}
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
deps.log(`Installed RequestShield skill for Codex at ${destination}`);
|
|
79
|
+
deps.log("Restart Codex or start a new task before using the skill.");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** @param {string} target */
|
|
83
|
+
async function pathExists(target) {
|
|
84
|
+
try {
|
|
85
|
+
await lstat(target);
|
|
86
|
+
return true;
|
|
87
|
+
} catch (error) {
|
|
88
|
+
if (/** @type {NodeJS.ErrnoException} */ (error).code === "ENOENT") return false;
|
|
89
|
+
throw error;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import readline from "node:readline/promises";
|
|
4
|
+
import { stdin, stdout } from "node:process";
|
|
5
|
+
import { CliError } from "../errors.mjs";
|
|
6
|
+
import { objectString } from "../api-client.mjs";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @param {{ yes: boolean }} options
|
|
10
|
+
* @param {{ api: { rotateKeys(accessToken: string): Promise<{body: unknown}> }, sessions: { loadToken(): Promise<string> }, log: (message: string) => void, confirm?: () => Promise<boolean> }} deps
|
|
11
|
+
*/
|
|
12
|
+
export async function createKeys(options, deps) {
|
|
13
|
+
if (!options.yes) {
|
|
14
|
+
const accepted = await (deps.confirm ?? confirmRotation)();
|
|
15
|
+
if (!accepted) throw new CliError("Key rotation cancelled", { code: "CANCELLED", exitCode: 2 });
|
|
16
|
+
}
|
|
17
|
+
const token = await deps.sessions.loadToken();
|
|
18
|
+
const result = await deps.api.rotateKeys(token);
|
|
19
|
+
const appKey = objectString(result.body, "appKey");
|
|
20
|
+
const apiSecret = objectString(result.body, "apiSecret");
|
|
21
|
+
if (!appKey || !apiSecret) {
|
|
22
|
+
throw new CliError("The key rotation response did not contain appKey and apiSecret", {
|
|
23
|
+
code: "INVALID_RESPONSE",
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
deps.log("New RequestShield keys are active. Previous keys are deactivated.");
|
|
27
|
+
deps.log(`App Key: ${appKey}`);
|
|
28
|
+
deps.log(`Secret Key (shown once): ${apiSecret}`);
|
|
29
|
+
deps.log("Store the Secret Key in your backend secret manager now. The CLI did not save it.");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function confirmRotation() {
|
|
33
|
+
if (!stdin.isTTY || !stdout.isTTY) {
|
|
34
|
+
throw new CliError("Refusing non-interactive key rotation without --yes", {
|
|
35
|
+
code: "CONFIRMATION_REQUIRED",
|
|
36
|
+
exitCode: 2,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
const prompt = readline.createInterface({ input: stdin, output: stdout });
|
|
40
|
+
try {
|
|
41
|
+
const answer = await prompt.question("This deactivates the current keys. Type ROTATE to continue: ");
|
|
42
|
+
return answer === "ROTATE";
|
|
43
|
+
} finally {
|
|
44
|
+
prompt.close();
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
4
|
+
import { CliError } from "../errors.mjs";
|
|
5
|
+
import { objectString } from "../api-client.mjs";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @param {{ api: { startSignin(): Promise<{body: unknown}>, pollSignin(deviceCode: string): Promise<{ok: boolean, body: unknown}> }, sessions: { save(session: {accessToken: string, account?: unknown}): Promise<void> }, log: (message: string) => void, wait?: (milliseconds: number) => Promise<unknown> }} deps
|
|
9
|
+
*/
|
|
10
|
+
export async function signin(deps) {
|
|
11
|
+
const started = await deps.api.startSignin();
|
|
12
|
+
const deviceCode = required(started.body, "deviceCode");
|
|
13
|
+
const userCode = required(started.body, "userCode");
|
|
14
|
+
const verificationUri = required(started.body, "verificationUri");
|
|
15
|
+
const expiresIn = positiveNumber(started.body, "expiresIn", 600);
|
|
16
|
+
let interval = positiveNumber(started.body, "interval", 5);
|
|
17
|
+
|
|
18
|
+
deps.log(`Open ${verificationUri}`);
|
|
19
|
+
deps.log(`Enter code: ${userCode}`);
|
|
20
|
+
|
|
21
|
+
const deadline = Date.now() + expiresIn * 1000;
|
|
22
|
+
while (Date.now() < deadline) {
|
|
23
|
+
await (deps.wait ?? delay)(interval * 1000);
|
|
24
|
+
const result = await deps.api.pollSignin(deviceCode);
|
|
25
|
+
const accessToken = objectString(result.body, "accessToken");
|
|
26
|
+
if (result.ok && accessToken) {
|
|
27
|
+
const account = result.body && typeof result.body === "object" ? Reflect.get(result.body, "account") : undefined;
|
|
28
|
+
await deps.sessions.save({ accessToken, account });
|
|
29
|
+
deps.log("Signed in successfully. The session is stored for the current user.");
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const code = objectString(result.body, "code");
|
|
33
|
+
if (code === "authorization_pending") continue;
|
|
34
|
+
if (code === "slow_down") {
|
|
35
|
+
interval += 5;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (code === "access_denied") {
|
|
39
|
+
throw new CliError("Sign-in was denied", { code, exitCode: 3 });
|
|
40
|
+
}
|
|
41
|
+
if (code === "expired_token") break;
|
|
42
|
+
throw new CliError(objectString(result.body, "message") ?? "Sign-in failed", {
|
|
43
|
+
code: code ?? "SIGNIN_FAILED",
|
|
44
|
+
exitCode: 3,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
throw new CliError("The sign-in code expired; run `requestshield signin` again", {
|
|
48
|
+
code: "SIGNIN_EXPIRED",
|
|
49
|
+
exitCode: 3,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** @param {unknown} body @param {string} name */
|
|
54
|
+
function required(body, name) {
|
|
55
|
+
const value = objectString(body, name);
|
|
56
|
+
if (!value) throw new CliError(`Sign-in response is missing ${name}`);
|
|
57
|
+
return value;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** @param {unknown} body @param {string} name @param {number} fallback */
|
|
61
|
+
function positiveNumber(body, name, fallback) {
|
|
62
|
+
if (!body || typeof body !== "object") return fallback;
|
|
63
|
+
const value = Reflect.get(body, name);
|
|
64
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback;
|
|
65
|
+
}
|
package/src/errors.mjs
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
export class CliError extends Error {
|
|
4
|
+
/**
|
|
5
|
+
* @param {string} message
|
|
6
|
+
* @param {{ code?: string, exitCode?: number }} [options]
|
|
7
|
+
*/
|
|
8
|
+
constructor(message, options = {}) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = "CliError";
|
|
11
|
+
this.code = options.code ?? "CLI_ERROR";
|
|
12
|
+
this.exitCode = options.exitCode ?? 1;
|
|
13
|
+
}
|
|
14
|
+
}
|
package/src/main.mjs
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// @ts-check
|
|
3
|
+
|
|
4
|
+
import { run } from "./cli.mjs";
|
|
5
|
+
import { CliError } from "./errors.mjs";
|
|
6
|
+
|
|
7
|
+
async function main() {
|
|
8
|
+
try {
|
|
9
|
+
await run(process.argv.slice(2));
|
|
10
|
+
} catch (error) {
|
|
11
|
+
const message =
|
|
12
|
+
error instanceof Error
|
|
13
|
+
? error.message
|
|
14
|
+
: String(error);
|
|
15
|
+
|
|
16
|
+
console.error(`requestshield: ${message}`);
|
|
17
|
+
|
|
18
|
+
process.exitCode =
|
|
19
|
+
error instanceof CliError
|
|
20
|
+
? error.exitCode
|
|
21
|
+
: 1;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
void main();
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { randomUUID } from "node:crypto";
|
|
7
|
+
import { spawnSync } from "node:child_process";
|
|
8
|
+
import { CliError } from "./errors.mjs";
|
|
9
|
+
|
|
10
|
+
export class SessionStore {
|
|
11
|
+
/** @param {{ env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform, homeDir?: string }} [options] */
|
|
12
|
+
constructor(options = {}) {
|
|
13
|
+
this.env = options.env ?? process.env;
|
|
14
|
+
this.platform = options.platform ?? process.platform;
|
|
15
|
+
this.homeDir = options.homeDir ?? os.homedir();
|
|
16
|
+
this.file = sessionPath(this.env, this.platform, this.homeDir);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** @param {{ accessToken: string, account?: unknown }} session */
|
|
20
|
+
async save(session) {
|
|
21
|
+
if (!session.accessToken) throw new CliError("Cannot store an empty session token");
|
|
22
|
+
const directory = path.dirname(this.file);
|
|
23
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
24
|
+
const temp = `${this.file}.${randomUUID()}.tmp`;
|
|
25
|
+
try {
|
|
26
|
+
await writeFile(temp, `${JSON.stringify(session)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
27
|
+
await chmod(temp, 0o600);
|
|
28
|
+
await rename(temp, this.file);
|
|
29
|
+
await chmod(this.file, 0o600);
|
|
30
|
+
if (this.platform === "win32") restrictWindowsAcl(this.file);
|
|
31
|
+
} finally {
|
|
32
|
+
await rm(temp, { force: true });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async loadToken() {
|
|
37
|
+
const environmentToken = this.env.REQUESTSHIELD_ACCESS_TOKEN?.trim();
|
|
38
|
+
if (environmentToken) return environmentToken;
|
|
39
|
+
let contents;
|
|
40
|
+
try {
|
|
41
|
+
contents = await readFile(this.file, "utf8");
|
|
42
|
+
} catch (error) {
|
|
43
|
+
if (/** @type {NodeJS.ErrnoException} */ (error).code === "ENOENT") {
|
|
44
|
+
throw new CliError("Not signed in. Run `requestshield signin` first.", {
|
|
45
|
+
code: "NOT_SIGNED_IN",
|
|
46
|
+
exitCode: 3,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
let session;
|
|
52
|
+
try {
|
|
53
|
+
session = JSON.parse(contents);
|
|
54
|
+
} catch {
|
|
55
|
+
throw new CliError("The saved RequestShield session is invalid; sign in again", {
|
|
56
|
+
code: "INVALID_SESSION",
|
|
57
|
+
exitCode: 3,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
const token = session && typeof session === "object" ? session.accessToken : undefined;
|
|
61
|
+
if (typeof token !== "string" || token === "") {
|
|
62
|
+
throw new CliError("The saved RequestShield session is invalid; sign in again", {
|
|
63
|
+
code: "INVALID_SESSION",
|
|
64
|
+
exitCode: 3,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
return token;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** @param {NodeJS.ProcessEnv} env @param {NodeJS.Platform} platform @param {string} homeDir */
|
|
72
|
+
export function sessionPath(env, platform, homeDir) {
|
|
73
|
+
if (platform === "win32") {
|
|
74
|
+
return path.join(env.LOCALAPPDATA || path.join(homeDir, "AppData", "Local"), "IntelliFend", "RequestShield", "session.json");
|
|
75
|
+
}
|
|
76
|
+
const base = env.XDG_STATE_HOME || path.join(homeDir, ".local", "state");
|
|
77
|
+
return path.join(base, "intellifend", "requestshield", "session.json");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** @param {string} file */
|
|
81
|
+
function restrictWindowsAcl(file) {
|
|
82
|
+
const result = spawnSync("icacls.exe", [file, "/inheritance:r", "/grant:r", `${process.env.USERNAME}:(R,W)`], {
|
|
83
|
+
stdio: "ignore",
|
|
84
|
+
windowsHide: true,
|
|
85
|
+
});
|
|
86
|
+
if ((result.status ?? 1) !== 0) {
|
|
87
|
+
throw new CliError("The session was written but its Windows ACL could not be restricted; remove it and sign in again", {
|
|
88
|
+
code: "SESSION_PERMISSION_ERROR",
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|