faberwright 0.3.1 → 0.4.1
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 +183 -55
- package/dist/agent.js +20 -1
- package/dist/config.js +48 -5
- package/dist/credentials.js +134 -0
- package/dist/index.js +252 -9
- package/dist/llm.js +344 -6
- package/dist/models.js +180 -0
- package/dist/onboard.js +364 -0
- package/dist/pricing.js +272 -0
- package/dist/prompt.js +158 -21
- package/dist/routes.js +143 -0
- package/dist/settings.js +55 -0
- package/dist/sigv4.js +177 -0
- package/dist/usage.js +187 -42
- package/package.json +1 -1
package/dist/prompt.js
CHANGED
|
@@ -2,24 +2,63 @@ import pc from "picocolors";
|
|
|
2
2
|
let guardFn;
|
|
3
3
|
/** Wired by the CLI: pauses the composed-input pipe while the selector owns stdin. */
|
|
4
4
|
export function setSelectGuard(fn) { guardFn = fn; }
|
|
5
|
-
|
|
5
|
+
async function selectRaw(rl, question, options, defaultIndex = 0) {
|
|
6
6
|
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
7
7
|
const menu = options.map((o, i) => ` ${i + 1}) ${o}`).join("\n");
|
|
8
|
-
|
|
8
|
+
let ans;
|
|
9
|
+
try {
|
|
10
|
+
ans = (await rl.question(`${question}\n${menu}\nChoose [1-${options.length}] (default ${defaultIndex + 1}): `)).trim();
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
// stdin closed mid-prompt (piped input ran out, or Ctrl-D):
|
|
14
|
+
// take the default rather than surfacing a readline stack trace.
|
|
15
|
+
return defaultIndex;
|
|
16
|
+
}
|
|
9
17
|
const n = Number.parseInt(ans, 10);
|
|
10
18
|
return Number.isInteger(n) && n >= 1 && n <= options.length ? n - 1 : defaultIndex;
|
|
11
19
|
}
|
|
12
|
-
|
|
20
|
+
// Long lists (a provider can return dozens of models) are unusable with
|
|
21
|
+
// arrow keys alone, so typing filters the list as you go.
|
|
22
|
+
const searchable = options.length > 8;
|
|
23
|
+
console.log(pc.bold(question) +
|
|
24
|
+
pc.dim(searchable ? " ↑/↓ then Enter · type to filter" : " ↑/↓ then Enter, or 1-9"));
|
|
13
25
|
return new Promise((resolve) => {
|
|
14
|
-
let
|
|
15
|
-
let
|
|
26
|
+
let filter = "";
|
|
27
|
+
let view = options.map((_, i) => i); // indices currently shown
|
|
28
|
+
let cursor = Math.max(0, view.indexOf(defaultIndex));
|
|
29
|
+
let painted = 0; // rows drawn last time
|
|
30
|
+
const applyFilter = () => {
|
|
31
|
+
const q = filter.toLowerCase();
|
|
32
|
+
const next = options
|
|
33
|
+
.map((o, i) => [o, i])
|
|
34
|
+
.filter(([o]) => o.toLowerCase().includes(q))
|
|
35
|
+
.map(([, i]) => i);
|
|
36
|
+
view = next.length ? next : [];
|
|
37
|
+
cursor = 0;
|
|
38
|
+
};
|
|
16
39
|
const render = () => {
|
|
17
|
-
if (
|
|
18
|
-
process.stdout.write(`\x1b[${
|
|
19
|
-
|
|
20
|
-
|
|
40
|
+
if (painted)
|
|
41
|
+
process.stdout.write(`\x1b[${painted}A`);
|
|
42
|
+
const rows = view.length ? view.length : 1;
|
|
43
|
+
const extra = filter ? 1 : 0;
|
|
44
|
+
for (let r = 0; r < view.length; r++) {
|
|
21
45
|
process.stdout.write("\x1b[2K");
|
|
22
|
-
|
|
46
|
+
const i = view[r];
|
|
47
|
+
process.stdout.write((r === cursor ? pc.cyan(`❯ ${options[i]}`) : pc.dim(` ${options[i]}`)) + "\n");
|
|
48
|
+
}
|
|
49
|
+
if (!view.length) {
|
|
50
|
+
process.stdout.write("\x1b[2K" + pc.yellow(` no match for "${filter}"`) + "\n");
|
|
51
|
+
}
|
|
52
|
+
if (filter) {
|
|
53
|
+
process.stdout.write("\x1b[2K" + pc.dim(` filter: ${filter}`) + "\n");
|
|
54
|
+
}
|
|
55
|
+
// clear any rows the previous, longer render left behind
|
|
56
|
+
for (let r = rows + extra; r < painted; r++)
|
|
57
|
+
process.stdout.write("\x1b[2K\n");
|
|
58
|
+
painted = Math.max(rows + extra, painted);
|
|
59
|
+
if (painted > rows + extra) {
|
|
60
|
+
process.stdout.write(`\x1b[${painted - (rows + extra)}A`);
|
|
61
|
+
painted = rows + extra;
|
|
23
62
|
}
|
|
24
63
|
};
|
|
25
64
|
const stdin = process.stdin;
|
|
@@ -50,24 +89,48 @@ export async function select(rl, question, options, defaultIndex = 0) {
|
|
|
50
89
|
key = s[i];
|
|
51
90
|
i += 1;
|
|
52
91
|
}
|
|
53
|
-
if (key === "\x1b[A"
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
}
|
|
57
|
-
else if (key === "\x1b[B" || key === "j") {
|
|
58
|
-
idx = (idx + 1) % options.length;
|
|
92
|
+
if (key === "\x1b[A") {
|
|
93
|
+
if (view.length)
|
|
94
|
+
cursor = (cursor - 1 + view.length) % view.length;
|
|
59
95
|
render();
|
|
60
96
|
}
|
|
61
|
-
else if (key
|
|
62
|
-
|
|
97
|
+
else if (key === "\x1b[B") {
|
|
98
|
+
if (view.length)
|
|
99
|
+
cursor = (cursor + 1) % view.length;
|
|
63
100
|
render();
|
|
64
|
-
finish(idx);
|
|
65
|
-
done = true;
|
|
66
101
|
}
|
|
67
102
|
else if (key === "\r" || key === "\n") {
|
|
68
|
-
|
|
103
|
+
if (view.length) {
|
|
104
|
+
finish(view[cursor]);
|
|
105
|
+
done = true;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
else if (key === "\x7f" || key === "\b") { // backspace edits the filter
|
|
109
|
+
if (filter) {
|
|
110
|
+
filter = filter.slice(0, -1);
|
|
111
|
+
applyFilter();
|
|
112
|
+
render();
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
// Number shortcuts only while unfiltered; once you're typing, digits
|
|
116
|
+
// are part of the search term (model ids are full of them).
|
|
117
|
+
else if (!filter && !searchable && key >= "1" && key <= "9" && Number(key) <= options.length) {
|
|
118
|
+
finish(Number(key) - 1);
|
|
69
119
|
done = true;
|
|
70
120
|
}
|
|
121
|
+
else if (searchable && key >= " " && key !== "\x1b") {
|
|
122
|
+
filter += key;
|
|
123
|
+
applyFilter();
|
|
124
|
+
render();
|
|
125
|
+
}
|
|
126
|
+
else if (!searchable && (key === "k" || key === "j")) {
|
|
127
|
+
if (view.length)
|
|
128
|
+
cursor = (cursor + (key === "k" ? -1 : 1) + view.length) % view.length;
|
|
129
|
+
render();
|
|
130
|
+
}
|
|
131
|
+
// Ctrl-C or Esc cancels. Returning -1 used to leak out as an array
|
|
132
|
+
// index, crashing the caller with "cannot read properties of
|
|
133
|
+
// undefined" — a cancel must be a clean exit, not a bad index.
|
|
71
134
|
else if (key === "\x03" || key === "\x1b") {
|
|
72
135
|
finish(-1);
|
|
73
136
|
done = true;
|
|
@@ -78,3 +141,77 @@ export async function select(rl, question, options, defaultIndex = 0) {
|
|
|
78
141
|
stdin.on("data", onData);
|
|
79
142
|
});
|
|
80
143
|
}
|
|
144
|
+
/**
|
|
145
|
+
* Read a secret without echoing it. A pasted API key that appears on screen
|
|
146
|
+
* survives in terminal scrollback, `script` logs, and screen recordings — so
|
|
147
|
+
* the characters are consumed in raw mode and only a masked length is shown.
|
|
148
|
+
* Falls back to a normal read when there's no TTY (CI piping a key in).
|
|
149
|
+
*/
|
|
150
|
+
export async function readSecret(rl, promptText, io) {
|
|
151
|
+
const stdin = io?.stdin ?? process.stdin;
|
|
152
|
+
const stdout = io?.stdout ?? process.stdout;
|
|
153
|
+
if (!stdin.isTTY || !stdout.isTTY) {
|
|
154
|
+
return (await rl.question(promptText)).trim();
|
|
155
|
+
}
|
|
156
|
+
guardFn?.(true);
|
|
157
|
+
rl.pause();
|
|
158
|
+
const wasRaw = stdin.isRaw ?? false;
|
|
159
|
+
stdin.setRawMode(true);
|
|
160
|
+
stdin.resume();
|
|
161
|
+
stdout.write(promptText);
|
|
162
|
+
return new Promise((resolve) => {
|
|
163
|
+
let value = "";
|
|
164
|
+
const done = () => {
|
|
165
|
+
stdin.removeListener("data", onData);
|
|
166
|
+
stdin.setRawMode(wasRaw);
|
|
167
|
+
guardFn?.(false);
|
|
168
|
+
rl.resume();
|
|
169
|
+
stdout.write("\n");
|
|
170
|
+
resolve(value.trim());
|
|
171
|
+
};
|
|
172
|
+
const onData = (buf) => {
|
|
173
|
+
// Terminals wrap pasted text in bracketed-paste markers, ESC[200~ before
|
|
174
|
+
// and ESC[201~ after. The ESC byte is below space and gets dropped by the
|
|
175
|
+
// printable test below, but "[200~" is ordinary text and would be glued
|
|
176
|
+
// onto the secret — which is how a pasted key ends up rejected as
|
|
177
|
+
// malformed. Strip the markers, and any other escape sequence, first.
|
|
178
|
+
const chunk = buf.toString("utf8")
|
|
179
|
+
.replace(/\x1b\[20[01]~/g, "")
|
|
180
|
+
.replace(/\x1b\[[0-9;]*[A-Za-z~]/g, "")
|
|
181
|
+
.replace(/\x1b./g, "");
|
|
182
|
+
for (const ch of chunk) {
|
|
183
|
+
if (ch === "\r" || ch === "\n")
|
|
184
|
+
return done();
|
|
185
|
+
if (ch === "\x03") {
|
|
186
|
+
value = "";
|
|
187
|
+
return done();
|
|
188
|
+
} // Ctrl-C
|
|
189
|
+
if (ch === "\x7f" || ch === "\b") { // backspace
|
|
190
|
+
if (value.length)
|
|
191
|
+
value = value.slice(0, -1);
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
// Echo nothing at all, the way sudo and ssh do. Masking characters
|
|
195
|
+
// would still reveal the key's length, and a hundred dots for a long
|
|
196
|
+
// key looks like something went wrong.
|
|
197
|
+
if (ch >= " ")
|
|
198
|
+
value += ch;
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
stdin.on("data", onData);
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Arrow-key menu. Cancelling (Ctrl-C or Esc) exits the process cleanly rather
|
|
206
|
+
* than returning a sentinel index that every caller would have to check —
|
|
207
|
+
* one forgotten check produced a raw TypeError mid-setup.
|
|
208
|
+
*/
|
|
209
|
+
export async function select(rl, question, options, defaultIndex = 0) {
|
|
210
|
+
const picked = await selectRaw(rl, question, options, defaultIndex);
|
|
211
|
+
if (picked < 0 || picked >= options.length) {
|
|
212
|
+
process.stdout.write("\n");
|
|
213
|
+
rl.close();
|
|
214
|
+
process.exit(130); // 128 + SIGINT, the conventional cancel code
|
|
215
|
+
}
|
|
216
|
+
return picked;
|
|
217
|
+
}
|
package/dist/routes.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vendors, routes, and model aliases.
|
|
3
|
+
*
|
|
4
|
+
* Three levels, chosen in order:
|
|
5
|
+
* vendor — who makes the model (Anthropic, OpenAI-compatible)
|
|
6
|
+
* route — how you reach it and who owns authentication
|
|
7
|
+
* (direct API key, a cloud provider, a local server, a gateway)
|
|
8
|
+
* model — an alias (sonnet/opus/haiku) or a raw model id
|
|
9
|
+
*
|
|
10
|
+
* Aliases exist because the same model has different ids on different routes:
|
|
11
|
+
* the direct API calls it "claude-sonnet-5" while Bedrock wants a long
|
|
12
|
+
* region-prefixed inference-profile id. Aliases stay stable; the mapping moves.
|
|
13
|
+
* For cloud routes the mapping is USER-SUPPLIED — we never guess ids we can't
|
|
14
|
+
* verify, since a wrong one fails at request time with a confusing error.
|
|
15
|
+
*/
|
|
16
|
+
export const ROUTES = [
|
|
17
|
+
{
|
|
18
|
+
id: "anthropic-api",
|
|
19
|
+
vendor: "Anthropic",
|
|
20
|
+
label: "Anthropic API",
|
|
21
|
+
hint: "direct, pay per token with your own key",
|
|
22
|
+
wire: "anthropic",
|
|
23
|
+
baseUrl: "https://api.anthropic.com",
|
|
24
|
+
keyEnv: "ANTHROPIC_API_KEY",
|
|
25
|
+
implemented: true,
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
// AWS's bedrock-mantle endpoint speaks the first-party Messages dialect and
|
|
29
|
+
// authenticates with a Bedrock API key, so no SigV4 signing is needed —
|
|
30
|
+
// only the base URL and the credential differ from the direct route.
|
|
31
|
+
id: "bedrock",
|
|
32
|
+
vendor: "Anthropic",
|
|
33
|
+
label: "Amazon Bedrock",
|
|
34
|
+
hint: "your AWS account owns auth and billing",
|
|
35
|
+
wire: "anthropic",
|
|
36
|
+
needsRegion: true,
|
|
37
|
+
baseUrlTemplate: "https://bedrock-mantle.{region}.api.aws/anthropic",
|
|
38
|
+
keyEnv: "BEDROCK_API_KEY",
|
|
39
|
+
aliasesArePinned: true, // Bedrock model ids differ per region/deployment
|
|
40
|
+
implemented: true,
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
id: "vertex",
|
|
44
|
+
vendor: "Anthropic",
|
|
45
|
+
label: "Google Vertex AI",
|
|
46
|
+
hint: "your GCP project owns auth and billing",
|
|
47
|
+
wire: "anthropic",
|
|
48
|
+
aliasesArePinned: true,
|
|
49
|
+
implemented: false, // needs Google OAuth
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
id: "openai-api",
|
|
53
|
+
vendor: "OpenAI",
|
|
54
|
+
label: "OpenAI API",
|
|
55
|
+
hint: "direct, with your own key",
|
|
56
|
+
wire: "openai",
|
|
57
|
+
baseUrl: "https://api.openai.com/v1",
|
|
58
|
+
keyEnv: "OPENAI_API_KEY",
|
|
59
|
+
implemented: true,
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
id: "ollama",
|
|
63
|
+
vendor: "Local",
|
|
64
|
+
label: "Ollama (local)",
|
|
65
|
+
hint: "runs on your machine, no key, no cost",
|
|
66
|
+
wire: "openai",
|
|
67
|
+
baseUrl: "http://localhost:11434/v1",
|
|
68
|
+
aliasesArePinned: true,
|
|
69
|
+
implemented: true,
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
id: "custom",
|
|
73
|
+
vendor: "Other",
|
|
74
|
+
label: "OpenAI-compatible endpoint",
|
|
75
|
+
hint: "OpenRouter, Groq, Together, vLLM, a gateway…",
|
|
76
|
+
wire: "openai",
|
|
77
|
+
needsBaseUrl: true,
|
|
78
|
+
keyEnv: "OPENAI_API_KEY",
|
|
79
|
+
aliasesArePinned: true,
|
|
80
|
+
implemented: true,
|
|
81
|
+
},
|
|
82
|
+
];
|
|
83
|
+
export function getRoute(id) {
|
|
84
|
+
return ROUTES.find((r) => r.id === id);
|
|
85
|
+
}
|
|
86
|
+
export const DEFAULT_REGION = "us-east-1";
|
|
87
|
+
/** Endpoint for a route, substituting the region into region-scoped URLs. */
|
|
88
|
+
export function baseUrlFor(route, region) {
|
|
89
|
+
if (route.baseUrlTemplate) {
|
|
90
|
+
return route.baseUrlTemplate.replace("{region}", region || DEFAULT_REGION);
|
|
91
|
+
}
|
|
92
|
+
return route.baseUrl;
|
|
93
|
+
}
|
|
94
|
+
/** Vendors in menu order, each with its routes. */
|
|
95
|
+
export function vendors() {
|
|
96
|
+
const out = [];
|
|
97
|
+
for (const r of ROUTES) {
|
|
98
|
+
const found = out.find((v) => v.vendor === r.vendor);
|
|
99
|
+
if (found)
|
|
100
|
+
found.routes.push(r);
|
|
101
|
+
else
|
|
102
|
+
out.push({ vendor: r.vendor, routes: [r] });
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Built-in aliases for the direct Anthropic API. Ids on cloud routes differ
|
|
108
|
+
* per deployment and region, so those are pinned by the user instead.
|
|
109
|
+
*/
|
|
110
|
+
export const ANTHROPIC_MODELS = [
|
|
111
|
+
{ alias: "sonnet", id: "claude-sonnet-5", blurb: "balanced — good default for daily work" },
|
|
112
|
+
{ alias: "opus", id: "claude-opus-5", blurb: "most capable — complex, multi-step work" },
|
|
113
|
+
{ alias: "haiku", id: "claude-haiku-4-5-20251001", blurb: "fastest and cheapest — simple tasks" },
|
|
114
|
+
];
|
|
115
|
+
export const OPENAI_MODELS = [
|
|
116
|
+
{ alias: "gpt", id: "gpt-4o", blurb: "general purpose" },
|
|
117
|
+
{ alias: "mini", id: "gpt-4o-mini", blurb: "cheaper and faster" },
|
|
118
|
+
];
|
|
119
|
+
/** Models offered for a route; empty when ids must be pinned by the user. */
|
|
120
|
+
export function modelsForRoute(route) {
|
|
121
|
+
if (route.aliasesArePinned)
|
|
122
|
+
return [];
|
|
123
|
+
return route.wire === "anthropic" ? ANTHROPIC_MODELS : OPENAI_MODELS;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Turn whatever the user typed into a concrete model id.
|
|
127
|
+
* Order: explicit per-route pin > built-in alias > treat it as a raw id.
|
|
128
|
+
*/
|
|
129
|
+
export function resolveModel(input, route, pins = {}) {
|
|
130
|
+
const key = input.trim();
|
|
131
|
+
if (pins[key])
|
|
132
|
+
return pins[key];
|
|
133
|
+
const hit = modelsForRoute(route).find((m) => m.alias === key);
|
|
134
|
+
return hit ? hit.id : key;
|
|
135
|
+
}
|
|
136
|
+
/** Reverse lookup: show "sonnet (claude-sonnet-5)" instead of a bare id. */
|
|
137
|
+
export function describeModel(id, route, pins = {}) {
|
|
138
|
+
const pinned = Object.entries(pins).find(([, v]) => v === id);
|
|
139
|
+
if (pinned)
|
|
140
|
+
return `${pinned[0]} (${id})`;
|
|
141
|
+
const hit = modelsForRoute(route).find((m) => m.id === id);
|
|
142
|
+
return hit ? `${hit.alias} (${id})` : id;
|
|
143
|
+
}
|
package/dist/settings.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Global settings: ~/.faber/settings.json
|
|
3
|
+
*
|
|
4
|
+
* Holds named profiles — a route, a model, and any endpoint or credential
|
|
5
|
+
* REFERENCE that route needs. Credentials themselves are never stored here:
|
|
6
|
+
* a profile names the environment variable to read (`apiKeyEnv`), so this file
|
|
7
|
+
* stays safe to sync between machines or check into a dotfiles repo.
|
|
8
|
+
*
|
|
9
|
+
* Resolution order, highest first:
|
|
10
|
+
* env vars > <project>/.faber/config.json > active profile > defaults
|
|
11
|
+
* Project settings beat the global profile so a repo can pin its own model,
|
|
12
|
+
* and env vars beat everything so CI and one-off overrides always work.
|
|
13
|
+
*/
|
|
14
|
+
import * as fs from "node:fs";
|
|
15
|
+
import * as os from "node:os";
|
|
16
|
+
import * as path from "node:path";
|
|
17
|
+
export const DEFAULT_SETTINGS = {
|
|
18
|
+
activeProfile: "default",
|
|
19
|
+
profiles: {
|
|
20
|
+
default: { route: "anthropic-api", model: "sonnet", apiKeyEnv: "ANTHROPIC_API_KEY" },
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
export function settingsPath() {
|
|
24
|
+
return path.join(os.homedir(), ".faber", "settings.json");
|
|
25
|
+
}
|
|
26
|
+
export function loadSettings(file = settingsPath()) {
|
|
27
|
+
try {
|
|
28
|
+
const raw = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
29
|
+
const profiles = raw.profiles && typeof raw.profiles === "object"
|
|
30
|
+
? raw.profiles
|
|
31
|
+
: DEFAULT_SETTINGS.profiles;
|
|
32
|
+
const active = typeof raw.activeProfile === "string" && profiles[raw.activeProfile]
|
|
33
|
+
? raw.activeProfile
|
|
34
|
+
: Object.keys(profiles)[0] ?? "default";
|
|
35
|
+
return { activeProfile: active, profiles };
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return structuredClone(DEFAULT_SETTINGS); // absent or malformed -> defaults
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
export function saveSettings(s, file = settingsPath()) {
|
|
42
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
43
|
+
fs.writeFileSync(file, JSON.stringify(s, null, 2) + "\n");
|
|
44
|
+
}
|
|
45
|
+
export function activeProfile(s) {
|
|
46
|
+
return s.profiles[s.activeProfile] ?? DEFAULT_SETTINGS.profiles.default;
|
|
47
|
+
}
|
|
48
|
+
/** Update the active profile in place and persist. */
|
|
49
|
+
export function updateActive(patch, file = settingsPath()) {
|
|
50
|
+
const s = loadSettings(file);
|
|
51
|
+
const name = s.activeProfile;
|
|
52
|
+
s.profiles[name] = { ...activeProfile(s), ...patch };
|
|
53
|
+
saveSettings(s, file);
|
|
54
|
+
return s;
|
|
55
|
+
}
|
package/dist/sigv4.js
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AWS SigV4 request signing.
|
|
3
|
+
*
|
|
4
|
+
* Why this exists: in SageMaker Studio, EC2, ECS, Lambda and anywhere else AWS
|
|
5
|
+
* injects an execution role, there IS no API key — credentials arrive as an
|
|
6
|
+
* access key / secret / session token trio, and requests are authenticated by
|
|
7
|
+
* signing them. That's the natural auth path in those environments, and a
|
|
8
|
+
* Bedrock API key would be a second, unnecessary credential.
|
|
9
|
+
*
|
|
10
|
+
* Implemented directly rather than pulling in the AWS SDK: the signing
|
|
11
|
+
* algorithm is ~80 lines of HMAC chaining, and the SDK would add dozens of
|
|
12
|
+
* transitive dependencies to a tool whose whole install story is "zero native
|
|
13
|
+
* deps, nothing to compile".
|
|
14
|
+
*
|
|
15
|
+
* Verified against the signing test vectors AWS publishes for the algorithm.
|
|
16
|
+
*/
|
|
17
|
+
import { createHash, createHmac } from "node:crypto";
|
|
18
|
+
import * as fs from "node:fs";
|
|
19
|
+
import * as os from "node:os";
|
|
20
|
+
import * as path from "node:path";
|
|
21
|
+
const sha256 = (data) => createHash("sha256").update(data).digest("hex");
|
|
22
|
+
const hmac = (key, data) => createHmac("sha256", key).update(data, "utf8").digest();
|
|
23
|
+
/** ISO8601 basic format: 20260808T210000Z */
|
|
24
|
+
export function amzDate(d = new Date()) {
|
|
25
|
+
return d.toISOString().replace(/[:-]|\.\d{3}/g, "");
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Canonical request -> string to sign -> signing key -> Authorization header.
|
|
29
|
+
* Header names are lowercased and sorted; the payload is hashed. Any deviation
|
|
30
|
+
* produces a signature mismatch, so the ordering here is load-bearing.
|
|
31
|
+
*/
|
|
32
|
+
export function signRequest(opts) {
|
|
33
|
+
const { method, body, region, service, credentials } = opts;
|
|
34
|
+
const url = new URL(opts.url);
|
|
35
|
+
const now = opts.now ?? new Date();
|
|
36
|
+
const stamp = amzDate(now);
|
|
37
|
+
const date = stamp.slice(0, 8);
|
|
38
|
+
const headers = {
|
|
39
|
+
host: url.host,
|
|
40
|
+
"x-amz-date": stamp,
|
|
41
|
+
...Object.fromEntries(Object.entries(opts.headers ?? {}).map(([k, v]) => [k.toLowerCase(), v])),
|
|
42
|
+
};
|
|
43
|
+
if (credentials.sessionToken)
|
|
44
|
+
headers["x-amz-security-token"] = credentials.sessionToken;
|
|
45
|
+
const signedHeaderNames = Object.keys(headers).sort();
|
|
46
|
+
const canonicalHeaders = signedHeaderNames
|
|
47
|
+
.map((h) => `${h}:${headers[h].trim().replace(/\s+/g, " ")}\n`)
|
|
48
|
+
.join("");
|
|
49
|
+
const signedHeaders = signedHeaderNames.join(";");
|
|
50
|
+
// query params must be sorted and percent-encoded
|
|
51
|
+
const canonicalQuery = [...url.searchParams.entries()]
|
|
52
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
53
|
+
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
|
|
54
|
+
.join("&");
|
|
55
|
+
const payloadHash = sha256(body);
|
|
56
|
+
const canonicalRequest = [
|
|
57
|
+
method.toUpperCase(),
|
|
58
|
+
url.pathname || "/",
|
|
59
|
+
canonicalQuery,
|
|
60
|
+
canonicalHeaders,
|
|
61
|
+
signedHeaders,
|
|
62
|
+
payloadHash,
|
|
63
|
+
].join("\n");
|
|
64
|
+
const scope = `${date}/${region}/${service}/aws4_request`;
|
|
65
|
+
const stringToSign = [
|
|
66
|
+
"AWS4-HMAC-SHA256",
|
|
67
|
+
stamp,
|
|
68
|
+
scope,
|
|
69
|
+
sha256(canonicalRequest),
|
|
70
|
+
].join("\n");
|
|
71
|
+
const kDate = hmac(`AWS4${credentials.secretAccessKey}`, date);
|
|
72
|
+
const kRegion = hmac(kDate, region);
|
|
73
|
+
const kService = hmac(kRegion, service);
|
|
74
|
+
const kSigning = hmac(kService, "aws4_request");
|
|
75
|
+
const signature = createHmac("sha256", kSigning).update(stringToSign, "utf8").digest("hex");
|
|
76
|
+
return {
|
|
77
|
+
...headers,
|
|
78
|
+
authorization: `AWS4-HMAC-SHA256 Credential=${credentials.accessKeyId}/${scope}, ` +
|
|
79
|
+
`SignedHeaders=${signedHeaders}, Signature=${signature}`,
|
|
80
|
+
"x-amz-content-sha256": payloadHash,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
// ────────────────────────────────────────────── credential discovery
|
|
84
|
+
/**
|
|
85
|
+
* Find AWS credentials the way the SDKs do, in the same order. In SageMaker
|
|
86
|
+
* Studio, ECS and Lambda the environment or the container endpoint is
|
|
87
|
+
* populated automatically, so this returns credentials with no setup at all.
|
|
88
|
+
*/
|
|
89
|
+
export async function discoverAwsCredentials(profileName = process.env.AWS_PROFILE ?? "default") {
|
|
90
|
+
// 1. environment (SageMaker Studio, CI, explicit exports)
|
|
91
|
+
if (process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY) {
|
|
92
|
+
return {
|
|
93
|
+
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
|
|
94
|
+
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
|
|
95
|
+
sessionToken: process.env.AWS_SESSION_TOKEN,
|
|
96
|
+
source: "environment",
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
// 2. container credential endpoint (ECS, SageMaker, CodeBuild)
|
|
100
|
+
const relUri = process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI;
|
|
101
|
+
const fullUri = process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI;
|
|
102
|
+
if (relUri || fullUri) {
|
|
103
|
+
const url = fullUri ?? `http://169.254.170.2${relUri}`;
|
|
104
|
+
const creds = await fetchContainerCredentials(url);
|
|
105
|
+
if (creds)
|
|
106
|
+
return creds;
|
|
107
|
+
}
|
|
108
|
+
// 3. shared credentials file (a laptop with `aws configure` run)
|
|
109
|
+
const fromFile = readSharedCredentials(profileName);
|
|
110
|
+
if (fromFile)
|
|
111
|
+
return fromFile;
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
async function fetchContainerCredentials(url) {
|
|
115
|
+
try {
|
|
116
|
+
const ctl = new AbortController();
|
|
117
|
+
const timer = setTimeout(() => ctl.abort(), 3000);
|
|
118
|
+
const headers = {};
|
|
119
|
+
const token = process.env.AWS_CONTAINER_AUTHORIZATION_TOKEN;
|
|
120
|
+
if (token)
|
|
121
|
+
headers.authorization = token;
|
|
122
|
+
const res = await fetch(url, { headers, signal: ctl.signal });
|
|
123
|
+
clearTimeout(timer);
|
|
124
|
+
if (!res.ok)
|
|
125
|
+
return undefined;
|
|
126
|
+
const b = await res.json();
|
|
127
|
+
if (!b.AccessKeyId || !b.SecretAccessKey)
|
|
128
|
+
return undefined;
|
|
129
|
+
return {
|
|
130
|
+
accessKeyId: b.AccessKeyId,
|
|
131
|
+
secretAccessKey: b.SecretAccessKey,
|
|
132
|
+
sessionToken: b.Token,
|
|
133
|
+
source: "container credentials endpoint",
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return undefined;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
/** Minimal INI reader for ~/.aws/credentials — no dependency needed. */
|
|
141
|
+
export function readSharedCredentials(profileName = "default", file = path.join(os.homedir(), ".aws", "credentials")) {
|
|
142
|
+
let text;
|
|
143
|
+
try {
|
|
144
|
+
text = fs.readFileSync(file, "utf8");
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
return undefined;
|
|
148
|
+
}
|
|
149
|
+
const wanted = profileName.replace(/^profile\s+/, "");
|
|
150
|
+
let current = "";
|
|
151
|
+
const section = {};
|
|
152
|
+
for (const raw of text.split("\n")) {
|
|
153
|
+
const line = raw.split(/[#;]/)[0].trim();
|
|
154
|
+
if (!line)
|
|
155
|
+
continue;
|
|
156
|
+
const header = /^\[(.+)\]$/.exec(line);
|
|
157
|
+
if (header) {
|
|
158
|
+
current = header[1].replace(/^profile\s+/, "").trim();
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
if (current !== wanted)
|
|
162
|
+
continue;
|
|
163
|
+
const eq = line.indexOf("=");
|
|
164
|
+
if (eq === -1)
|
|
165
|
+
continue;
|
|
166
|
+
section[line.slice(0, eq).trim().toLowerCase()] = line.slice(eq + 1).trim();
|
|
167
|
+
}
|
|
168
|
+
const id = section["aws_access_key_id"], secret = section["aws_secret_access_key"];
|
|
169
|
+
if (!id || !secret)
|
|
170
|
+
return undefined;
|
|
171
|
+
return {
|
|
172
|
+
accessKeyId: id,
|
|
173
|
+
secretAccessKey: secret,
|
|
174
|
+
sessionToken: section["aws_session_token"],
|
|
175
|
+
source: `~/.aws/credentials [${wanted}]`,
|
|
176
|
+
};
|
|
177
|
+
}
|