@traice/codex-collector 0.1.2 → 0.1.3
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 +30 -5
- package/collector.mjs +19 -1
- package/credentials.mjs +75 -0
- package/install.mjs +18 -2
- package/package.json +7 -3
package/README.md
CHANGED
|
@@ -71,9 +71,9 @@ The installer:
|
|
|
71
71
|
- creates or reuses the `internal-spend-poc` workspace in local dev
|
|
72
72
|
- enables the workspace `internalSpendEnabled` flag
|
|
73
73
|
- creates a real API key for `/api/v1/internal-usage`
|
|
74
|
-
-
|
|
75
|
-
|
|
76
|
-
|
|
74
|
+
- stores the API key in macOS Keychain, Windows Credential Manager, or Linux
|
|
75
|
+
Secret Service and writes only its reference to
|
|
76
|
+
`~/.traice/internal-spend-codex/install.json`
|
|
77
77
|
- appends an idempotent trAIce `[otel]` block to user-level
|
|
78
78
|
`~/.codex/config.toml`
|
|
79
79
|
|
|
@@ -145,8 +145,20 @@ contract and fill employee/team fields directly or via identity mapping.
|
|
|
145
145
|
The API key can be provided with `TRAICE_API_KEY`, `--api-key-stdin`, or
|
|
146
146
|
`--api-key`. Prefer `TRAICE_API_KEY` or `--api-key-stdin` for user installs so
|
|
147
147
|
keys do not end up in shell history. A later `install` reuses the saved key, and
|
|
148
|
-
`collect`
|
|
149
|
-
|
|
148
|
+
`collect` resolves it from secure storage, so neither command prompts again.
|
|
149
|
+
|
|
150
|
+
Credential storage defaults to `--credential-store auto`. It uses the native OS
|
|
151
|
+
credential manager when available. On headless systems it falls back explicitly
|
|
152
|
+
to `~/.traice/internal-spend-codex/credentials.json` with a user-only directory
|
|
153
|
+
and file (`0700`/`0600` on POSIX); that fallback is not encrypted at rest. Use
|
|
154
|
+
`--credential-store keyring` to require native secure storage and fail rather
|
|
155
|
+
than fall back, or `--credential-store file` for an externally encrypted or
|
|
156
|
+
managed environment. Existing plaintext `install.json` credentials migrate on
|
|
157
|
+
the next `install` or `collect`.
|
|
158
|
+
|
|
159
|
+
For containers, CI, MDM, or an external secret manager, inject
|
|
160
|
+
`TRAICE_API_KEY` only into the `collect` process. That override is used without
|
|
161
|
+
being persisted.
|
|
150
162
|
|
|
151
163
|
Private config and collector state default to `~/.traice/internal-spend-codex`.
|
|
152
164
|
Use `--out /path/to/private-dir` only when an org installer manages that
|
|
@@ -187,3 +199,16 @@ The collector keeps local dedupe state in:
|
|
|
187
199
|
```
|
|
188
200
|
|
|
189
201
|
so restarting it should not intentionally duplicate historical rows.
|
|
202
|
+
|
|
203
|
+
## Releasing
|
|
204
|
+
|
|
205
|
+
Do not run `npm publish` locally. From the platform repository, run:
|
|
206
|
+
|
|
207
|
+
```bash
|
|
208
|
+
npm run release:prepare
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
This opens a version PR. Merging the reviewed PR triggers GitHub Actions and npm
|
|
212
|
+
trusted publishing (OIDC), so publishing never requires an npm token or an
|
|
213
|
+
interactive `npm login`. Pass `minor`, `major`, or an explicit version after `--`
|
|
214
|
+
when the default patch bump is not appropriate.
|
package/collector.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statS
|
|
|
6
6
|
import { homedir } from "node:os";
|
|
7
7
|
import { basename, dirname, join, resolve } from "node:path";
|
|
8
8
|
import { gunzipSync } from "node:zlib";
|
|
9
|
+
import { readApiKeyCredential, storeApiKey } from "./credentials.mjs";
|
|
9
10
|
|
|
10
11
|
const DEFAULT_OUT_DIR = "~/.traice/internal-spend-codex";
|
|
11
12
|
const LEGACY_OUT_DIR = ".traice-internal-spend";
|
|
@@ -17,16 +18,18 @@ const collectorStartedAtMs = Date.now();
|
|
|
17
18
|
const pendingTurnLatency = new Map();
|
|
18
19
|
|
|
19
20
|
const args = parseArgs(process.argv.slice(2));
|
|
21
|
+
const inheritedApiKey = process.env.TRAICE_API_KEY;
|
|
20
22
|
loadEnvFile(resolve("apps/web/.env"));
|
|
21
23
|
|
|
22
24
|
const configPath = args.get("config") ? resolveHome(args.get("config")) : findDefaultInstallConfigPath();
|
|
23
25
|
const outDir = resolveHome(args.get("out") ?? dirname(configPath));
|
|
24
26
|
const installConfig = readJsonIfExists(configPath) ?? {};
|
|
27
|
+
const apiKey = await resolveCollectorApiKey();
|
|
25
28
|
const config = {
|
|
26
29
|
serverUrl: normalizeUrl(
|
|
27
30
|
args.get("server-url") ?? installConfig.serverUrl ?? process.env.TRAICE_API_URL ?? "http://127.0.0.1:3003",
|
|
28
31
|
),
|
|
29
|
-
apiKey
|
|
32
|
+
apiKey,
|
|
30
33
|
workspaceSlug: args.get("workspace-slug") ?? installConfig.workspaceSlug ?? "internal-spend-poc",
|
|
31
34
|
sourceKey: args.get("source-key") ?? installConfig.sourceKey ?? "codex-local",
|
|
32
35
|
sourceName: args.get("source-name") ?? installConfig.sourceName ?? "Codex local collector",
|
|
@@ -55,6 +58,21 @@ const config = {
|
|
|
55
58
|
maxBatchSize: parsePositiveInt(args.get("max-batch-size") ?? installConfig.maxBatchSize ?? DEFAULT_MAX_BATCH_SIZE),
|
|
56
59
|
};
|
|
57
60
|
|
|
61
|
+
async function resolveCollectorApiKey() {
|
|
62
|
+
const explicitApiKey = readApiKey() ?? inheritedApiKey;
|
|
63
|
+
if (explicitApiKey) return explicitApiKey;
|
|
64
|
+
if (installConfig.apiKeyCredential) return readApiKeyCredential(installConfig.apiKeyCredential);
|
|
65
|
+
if (installConfig.apiKey) {
|
|
66
|
+
const stored = await storeApiKey(configPath, installConfig.apiKey, "auto");
|
|
67
|
+
const migrated = { ...installConfig, apiKeyCredential: stored.credential };
|
|
68
|
+
delete migrated.apiKey;
|
|
69
|
+
writeFileSync(configPath, `${JSON.stringify(migrated, null, 2)}\n`, { mode: 0o600 });
|
|
70
|
+
if (stored.warning) console.warn(`[codex-otel] ${stored.warning}`);
|
|
71
|
+
return readApiKeyCredential(stored.credential);
|
|
72
|
+
}
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
58
76
|
if (!config.apiKey) {
|
|
59
77
|
console.error(
|
|
60
78
|
[
|
package/credentials.mjs
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
const KEYRING_SERVICE = "trAIce Codex Collector";
|
|
6
|
+
|
|
7
|
+
export async function storeApiKey(configPath, apiKey, mode = "auto", dependencies = {}) {
|
|
8
|
+
const account = `config-${createHash("sha256").update(resolve(configPath)).digest("hex").slice(0, 24)}`;
|
|
9
|
+
if (mode !== "file") {
|
|
10
|
+
try {
|
|
11
|
+
const entry = await createEntry(KEYRING_SERVICE, account, dependencies);
|
|
12
|
+
await entry.setPassword(apiKey);
|
|
13
|
+
if ((await entry.getPassword()) !== apiKey) throw new Error("credential verification failed");
|
|
14
|
+
return { credential: { backend: "os-keyring", service: KEYRING_SERVICE, account } };
|
|
15
|
+
} catch (error) {
|
|
16
|
+
if (mode === "keyring") throw new Error(`OS credential store unavailable: ${errorMessage(error)}`);
|
|
17
|
+
return {
|
|
18
|
+
credential: writeProtectedFile(configPath, apiKey),
|
|
19
|
+
warning: `OS credential store unavailable; using a user-only protected file (${errorMessage(error)}).`,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return { credential: writeProtectedFile(configPath, apiKey) };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function readApiKeyCredential(credential, dependencies = {}) {
|
|
27
|
+
if (credential?.backend === "os-keyring") {
|
|
28
|
+
const entry = await createEntry(credential.service, credential.account, dependencies);
|
|
29
|
+
const apiKey = await entry.getPassword();
|
|
30
|
+
if (!apiKey) throw new Error("Collector API key was not found in the OS credential store. Re-run install.");
|
|
31
|
+
return apiKey;
|
|
32
|
+
}
|
|
33
|
+
if (credential?.backend === "protected-file") {
|
|
34
|
+
const stored = dependencies.readJson?.(credential.path) ?? readJson(credential.path);
|
|
35
|
+
if (stored?.apiKey) return stored.apiKey;
|
|
36
|
+
throw new Error(`Collector API key was not found in ${credential.path}. Re-run install.`);
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function createEntry(service, account, dependencies) {
|
|
42
|
+
if (dependencies.createEntry) return dependencies.createEntry(service, account);
|
|
43
|
+
const { AsyncEntry } = await import("@napi-rs/keyring");
|
|
44
|
+
return new AsyncEntry(service, account);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function writeProtectedFile(configPath, apiKey) {
|
|
48
|
+
const directory = dirname(resolve(configPath));
|
|
49
|
+
const path = resolve(directory, "credentials.json");
|
|
50
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
51
|
+
try {
|
|
52
|
+
chmodSync(directory, 0o700);
|
|
53
|
+
} catch {
|
|
54
|
+
// Windows uses the ACL inherited from the user's profile directory.
|
|
55
|
+
}
|
|
56
|
+
writeFileSync(path, `${JSON.stringify({ apiKey }, null, 2)}\n`, { mode: 0o600 });
|
|
57
|
+
try {
|
|
58
|
+
chmodSync(path, 0o600);
|
|
59
|
+
} catch {
|
|
60
|
+
// Best effort on non-POSIX filesystems.
|
|
61
|
+
}
|
|
62
|
+
return { backend: "protected-file", path };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function readJson(path) {
|
|
66
|
+
try {
|
|
67
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
68
|
+
} catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function errorMessage(error) {
|
|
74
|
+
return error instanceof Error ? error.message : String(error);
|
|
75
|
+
}
|
package/install.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync, copyFile
|
|
|
5
5
|
import { homedir, hostname, userInfo } from "node:os";
|
|
6
6
|
import { resolve } from "node:path";
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { readApiKeyCredential, storeApiKey } from "./credentials.mjs";
|
|
8
9
|
|
|
9
10
|
const DEFAULT_OUT_DIR = "~/.traice/internal-spend-codex";
|
|
10
11
|
const LEGACY_INSTALL_CONFIG = ".traice-internal-spend/install.json";
|
|
@@ -46,6 +47,7 @@ const sourcePrincipal = args.get("source-principal") ?? `${hostname()}:${userInf
|
|
|
46
47
|
const seatMonthlyUsd = parseOptionalMoney(args.get("seat-monthly-usd") ?? process.env.TRAICE_SEAT_MONTHLY_USD);
|
|
47
48
|
const providedApiKey = readApiKey();
|
|
48
49
|
const backfillHours = parseBackfillHours(args.get("backfill-hours") ?? "all");
|
|
50
|
+
const credentialStore = parseCredentialStore(args.get("credential-store") ?? "auto");
|
|
49
51
|
|
|
50
52
|
mkdirSync(outDir, { recursive: true, mode: 0o700 });
|
|
51
53
|
try {
|
|
@@ -55,19 +57,23 @@ try {
|
|
|
55
57
|
}
|
|
56
58
|
|
|
57
59
|
const previousInstall = readJsonIfExists(installPath) ?? readLegacyInstallIfDefaultOut();
|
|
58
|
-
|
|
60
|
+
let previousApiKey = typeof previousInstall?.apiKey === "string" ? previousInstall.apiKey : null;
|
|
61
|
+
if (!providedApiKey && !previousApiKey && previousInstall?.apiKeyCredential) {
|
|
62
|
+
previousApiKey = await readApiKeyCredential(previousInstall.apiKeyCredential);
|
|
63
|
+
}
|
|
59
64
|
|
|
60
65
|
let prisma = null;
|
|
61
66
|
|
|
62
67
|
try {
|
|
63
68
|
const reusableApiKey = providedApiKey || previousApiKey;
|
|
64
69
|
const setup = reusableApiKey ? providedApiKeySetup(reusableApiKey, !providedApiKey) : await localDevSetup();
|
|
70
|
+
const storedApiKey = await storeApiKey(installPath, setup.key.raw, credentialStore);
|
|
65
71
|
|
|
66
72
|
const installConfig = {
|
|
67
73
|
version: 2,
|
|
68
74
|
installedAt: new Date().toISOString(),
|
|
69
75
|
serverUrl,
|
|
70
|
-
|
|
76
|
+
apiKeyCredential: storedApiKey.credential,
|
|
71
77
|
apiKeyPrefix: setup.key.prefix,
|
|
72
78
|
workspaceId: setup.workspace.id,
|
|
73
79
|
workspaceSlug: setup.workspace.slug,
|
|
@@ -110,6 +116,10 @@ try {
|
|
|
110
116
|
mode: setup.mode,
|
|
111
117
|
workspace: setup.workspace,
|
|
112
118
|
apiKey: { prefix: setup.key.prefix, reused: setup.key.reused },
|
|
119
|
+
credentialStorage: {
|
|
120
|
+
backend: storedApiKey.credential.backend,
|
|
121
|
+
warning: storedApiKey.warning,
|
|
122
|
+
},
|
|
113
123
|
identity: { employeeEmail, employeeName, teamName, sourcePrincipal },
|
|
114
124
|
cost: { seatMonthlyUsd },
|
|
115
125
|
installConfig: installPath,
|
|
@@ -464,6 +474,12 @@ function parseBackfillHours(value) {
|
|
|
464
474
|
return parsed;
|
|
465
475
|
}
|
|
466
476
|
|
|
477
|
+
function parseCredentialStore(value) {
|
|
478
|
+
if (["auto", "keyring", "file"].includes(value)) return value;
|
|
479
|
+
console.error(`Invalid credential store: ${value}. Use "auto", "keyring", or "file".`);
|
|
480
|
+
process.exit(2);
|
|
481
|
+
}
|
|
482
|
+
|
|
467
483
|
function generateApiKey() {
|
|
468
484
|
const raw = `lm_live_${randomBytes(24).toString("hex")}`;
|
|
469
485
|
return {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@traice/codex-collector",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "Local Codex usage collector for trAIce Internal Spend.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"type": "module",
|
|
@@ -14,13 +14,16 @@
|
|
|
14
14
|
"engines": {
|
|
15
15
|
"node": ">=20"
|
|
16
16
|
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@napi-rs/keyring": "^1.3.0"
|
|
19
|
+
},
|
|
17
20
|
"repository": {
|
|
18
21
|
"type": "git",
|
|
19
|
-
"url": "git+
|
|
22
|
+
"url": "git+https://github.com/runtraice/trAIce.git",
|
|
20
23
|
"directory": "tools/internal-spend-codex"
|
|
21
24
|
},
|
|
22
25
|
"bugs": {
|
|
23
|
-
"url": "https://github.com/
|
|
26
|
+
"url": "https://github.com/runtraice/trAIce/issues"
|
|
24
27
|
},
|
|
25
28
|
"homepage": "https://runtraice.com",
|
|
26
29
|
"keywords": [
|
|
@@ -37,6 +40,7 @@
|
|
|
37
40
|
"files": [
|
|
38
41
|
"cli.mjs",
|
|
39
42
|
"collector.mjs",
|
|
43
|
+
"credentials.mjs",
|
|
40
44
|
"install.mjs",
|
|
41
45
|
"README.md"
|
|
42
46
|
]
|