@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 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
- - writes private collector config, including the API key, to
75
- `~/.traice/internal-spend-codex/install.json` (user-only mode on macOS/Linux;
76
- the user-profile ACL on Windows)
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` always reads it from the private config, so neither command prompts
149
- again unless the config was removed.
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: readApiKey() ?? installConfig.apiKey ?? process.env.TRAICE_API_KEY,
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
  [
@@ -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
- const previousApiKey = typeof previousInstall?.apiKey === "string" ? previousInstall.apiKey : null;
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
- apiKey: setup.key.raw,
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.2",
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+ssh://git@ssh.github.com/shmulikdav/trAIce.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/shmulikdav/trAIce/issues"
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
  ]