@gajae-code/coding-agent 0.5.1 → 0.5.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/CHANGELOG.md +17 -0
- package/README.md +1 -1
- package/dist/types/cli/setup-cli.d.ts +8 -1
- package/dist/types/commands/setup.d.ts +7 -0
- package/dist/types/config/file-lock.d.ts +24 -2
- package/dist/types/config/model-registry.d.ts +4 -0
- package/dist/types/config/models-config-schema.d.ts +5 -0
- package/dist/types/config/settings-schema.d.ts +62 -0
- package/dist/types/gjc-runtime/state-writer.d.ts +64 -2
- package/dist/types/gjc-runtime/ultragoal-guard.d.ts +10 -0
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +29 -0
- package/dist/types/modes/components/provider-onboarding-selector.d.ts +1 -1
- package/dist/types/modes/interactive-mode.d.ts +1 -1
- package/dist/types/modes/rpc/rpc-mode.d.ts +56 -1
- package/dist/types/modes/shared/agent-wire/unattended-session.d.ts +10 -0
- package/dist/types/modes/theme/defaults/index.d.ts +302 -0
- package/dist/types/modes/theme/theme.d.ts +1 -0
- package/dist/types/modes/types.d.ts +1 -1
- package/dist/types/session/history-storage.d.ts +2 -2
- package/dist/types/session/session-manager.d.ts +10 -1
- package/dist/types/setup/credential-import.d.ts +79 -0
- package/dist/types/task/executor.d.ts +1 -0
- package/dist/types/task/render.d.ts +1 -1
- package/dist/types/tools/subagent-render.d.ts +7 -1
- package/dist/types/tools/subagent.d.ts +21 -0
- package/dist/types/tools/ultragoal-ask-guard.d.ts +5 -0
- package/dist/types/web/search/index.d.ts +4 -4
- package/dist/types/web/search/provider.d.ts +16 -20
- package/dist/types/web/search/providers/base.d.ts +2 -1
- package/dist/types/web/search/providers/openai-compatible.d.ts +9 -0
- package/dist/types/web/search/types.d.ts +14 -2
- package/package.json +7 -7
- package/scripts/build-binary.ts +7 -0
- package/src/cli/args.ts +2 -0
- package/src/cli/fast-help.ts +2 -0
- package/src/cli/setup-cli.ts +138 -3
- package/src/commands/setup.ts +5 -1
- package/src/commands/ultragoal.ts +3 -1
- package/src/config/file-lock-gc.ts +14 -2
- package/src/config/file-lock.ts +54 -12
- package/src/config/model-profile-activation.ts +15 -3
- package/src/config/model-profiles.ts +15 -15
- package/src/config/model-registry.ts +21 -1
- package/src/config/models-config-schema.ts +1 -0
- package/src/config/settings-schema.ts +62 -0
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +30 -8
- package/src/gjc-runtime/deep-interview-recorder.ts +40 -0
- package/src/gjc-runtime/launch-tmux.ts +3 -4
- package/src/gjc-runtime/ralplan-runtime.ts +174 -12
- package/src/gjc-runtime/state-runtime.ts +2 -1
- package/src/gjc-runtime/state-writer.ts +254 -7
- package/src/gjc-runtime/tmux-gc.ts +2 -1
- package/src/gjc-runtime/ultragoal-guard.ts +155 -0
- package/src/gjc-runtime/ultragoal-runtime.ts +1227 -31
- package/src/gjc-runtime/workflow-manifest.generated.json +44 -0
- package/src/gjc-runtime/workflow-manifest.ts +12 -0
- package/src/harness-control-plane/owner.ts +3 -2
- package/src/harness-control-plane/rpc-adapter.ts +1 -1
- package/src/hooks/skill-state.ts +121 -2
- package/src/internal-urls/docs-index.generated.ts +13 -9
- package/src/lsp/defaults.json +1 -0
- package/src/main.ts +14 -4
- package/src/modes/acp/acp-agent.ts +4 -2
- package/src/modes/bridge/bridge-mode.ts +2 -1
- package/src/modes/components/history-search.ts +5 -2
- package/src/modes/components/model-selector.ts +26 -0
- package/src/modes/components/provider-onboarding-selector.ts +6 -1
- package/src/modes/controllers/selector-controller.ts +80 -1
- package/src/modes/interactive-mode.ts +11 -1
- package/src/modes/rpc/rpc-mode.ts +132 -18
- package/src/modes/shared/agent-wire/command-dispatch.ts +5 -2
- package/src/modes/shared/agent-wire/host-tool-bridge.ts +3 -0
- package/src/modes/shared/agent-wire/unattended-session.ts +16 -1
- package/src/modes/theme/defaults/claude-code.json +100 -0
- package/src/modes/theme/defaults/codex.json +100 -0
- package/src/modes/theme/defaults/index.ts +6 -0
- package/src/modes/theme/defaults/opencode.json +102 -0
- package/src/modes/theme/theme.ts +2 -2
- package/src/modes/types.ts +1 -1
- package/src/prompts/agents/executor.md +5 -2
- package/src/sdk.ts +12 -1
- package/src/session/agent-session.ts +22 -11
- package/src/session/history-storage.ts +32 -11
- package/src/session/session-manager.ts +70 -18
- package/src/setup/credential-import.ts +429 -0
- package/src/skill-state/deep-interview-mutation-guard.ts +2 -1
- package/src/task/executor.ts +7 -1
- package/src/task/render.ts +18 -7
- package/src/tools/ask.ts +4 -2
- package/src/tools/cron.ts +1 -1
- package/src/tools/subagent-render.ts +119 -29
- package/src/tools/subagent.ts +147 -7
- package/src/tools/ultragoal-ask-guard.ts +39 -0
- package/src/web/search/index.ts +25 -25
- package/src/web/search/provider.ts +178 -87
- package/src/web/search/providers/base.ts +2 -1
- package/src/web/search/providers/openai-compatible.ts +151 -0
- package/src/web/search/types.ts +47 -22
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
isBlobRef,
|
|
41
41
|
isImageDataUrl,
|
|
42
42
|
MemoryBlobStore,
|
|
43
|
+
ResidentBlobMissingError,
|
|
43
44
|
resolveImageData,
|
|
44
45
|
resolveImageDataUrl,
|
|
45
46
|
resolveResidentImageDataSync,
|
|
@@ -1201,6 +1202,12 @@ interface ResidentBlobStores {
|
|
|
1201
1202
|
sessionFile?: string;
|
|
1202
1203
|
}
|
|
1203
1204
|
|
|
1205
|
+
type ResidentBlobMissingPolicy = "throw" | "placeholder";
|
|
1206
|
+
|
|
1207
|
+
function residentBlobMissingPlaceholder(error: ResidentBlobMissingError): string {
|
|
1208
|
+
return `[Session resident ${error.kind} blob missing: sha256:${error.hash}; original content unavailable]`;
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1204
1211
|
function externalizeResidentValueSync(obj: unknown, stores: ResidentBlobStores, key?: string): unknown {
|
|
1205
1212
|
if (obj === null || obj === undefined) return obj;
|
|
1206
1213
|
if (typeof obj === "string") {
|
|
@@ -1256,6 +1263,7 @@ function materializeResidentValueSync(
|
|
|
1256
1263
|
stores: ResidentBlobStores,
|
|
1257
1264
|
key?: string,
|
|
1258
1265
|
cache = new Map<string, string>(),
|
|
1266
|
+
missingPolicy: ResidentBlobMissingPolicy = "throw",
|
|
1259
1267
|
): unknown {
|
|
1260
1268
|
if (obj === null || obj === undefined) return obj;
|
|
1261
1269
|
if (typeof obj === "string") return obj;
|
|
@@ -1263,19 +1271,28 @@ function materializeResidentValueSync(
|
|
|
1263
1271
|
const cacheKey = `${obj.kind}:${obj.ref}`;
|
|
1264
1272
|
const cached = cache.get(cacheKey);
|
|
1265
1273
|
if (cached !== undefined) return cached;
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
?
|
|
1271
|
-
:
|
|
1274
|
+
let resolved: string;
|
|
1275
|
+
try {
|
|
1276
|
+
resolved =
|
|
1277
|
+
obj.kind === "imageUrl"
|
|
1278
|
+
? resolveResidentImageDataUrlSync(stores.imageStore, obj.ref, stores)
|
|
1279
|
+
: obj.kind === "imageData"
|
|
1280
|
+
? resolveResidentImageDataSync(stores.imageStore, obj.ref, stores)
|
|
1281
|
+
: resolveTextBlobSync(stores.textStore, obj.ref, stores);
|
|
1282
|
+
} catch (err) {
|
|
1283
|
+
if (missingPolicy === "placeholder" && err instanceof ResidentBlobMissingError) {
|
|
1284
|
+
resolved = residentBlobMissingPlaceholder(err);
|
|
1285
|
+
} else {
|
|
1286
|
+
throw err;
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1272
1289
|
cache.set(cacheKey, resolved);
|
|
1273
1290
|
return resolved;
|
|
1274
1291
|
}
|
|
1275
1292
|
if (Array.isArray(obj)) {
|
|
1276
1293
|
let changed = false;
|
|
1277
1294
|
const result = obj.map(item => {
|
|
1278
|
-
const newItem = materializeResidentValueSync(item, stores, key, cache);
|
|
1295
|
+
const newItem = materializeResidentValueSync(item, stores, key, cache, missingPolicy);
|
|
1279
1296
|
if (newItem !== item) changed = true;
|
|
1280
1297
|
return newItem;
|
|
1281
1298
|
});
|
|
@@ -1284,7 +1301,7 @@ function materializeResidentValueSync(
|
|
|
1284
1301
|
if (typeof obj === "object") {
|
|
1285
1302
|
let changed = false;
|
|
1286
1303
|
const entries = Object.entries(obj).map(([childKey, value]) => {
|
|
1287
|
-
const newValue = materializeResidentValueSync(value, stores, childKey, cache);
|
|
1304
|
+
const newValue = materializeResidentValueSync(value, stores, childKey, cache, missingPolicy);
|
|
1288
1305
|
if (newValue !== value) changed = true;
|
|
1289
1306
|
return [childKey, newValue] as const;
|
|
1290
1307
|
});
|
|
@@ -1297,8 +1314,9 @@ function materializeResidentEntrySync<T extends FileEntry | SessionEntry>(
|
|
|
1297
1314
|
entry: T,
|
|
1298
1315
|
stores: ResidentBlobStores,
|
|
1299
1316
|
cache: Map<string, string>,
|
|
1317
|
+
missingPolicy: ResidentBlobMissingPolicy = "throw",
|
|
1300
1318
|
): T {
|
|
1301
|
-
return materializeResidentValueSync(entry, stores, undefined, cache) as T;
|
|
1319
|
+
return materializeResidentValueSync(entry, stores, undefined, cache, missingPolicy) as T;
|
|
1302
1320
|
}
|
|
1303
1321
|
|
|
1304
1322
|
function materializeResidentEntriesSync<T extends FileEntry | SessionEntry>(
|
|
@@ -1308,6 +1326,37 @@ function materializeResidentEntriesSync<T extends FileEntry | SessionEntry>(
|
|
|
1308
1326
|
const cache = new Map<string, string>();
|
|
1309
1327
|
return entries.map(entry => materializeResidentEntrySync(entry, stores, cache));
|
|
1310
1328
|
}
|
|
1329
|
+
|
|
1330
|
+
function materializeResidentEntryForPersistenceSync<T extends FileEntry | SessionEntry>(
|
|
1331
|
+
entry: T,
|
|
1332
|
+
stores: ResidentBlobStores,
|
|
1333
|
+
cache: Map<string, string>,
|
|
1334
|
+
): T {
|
|
1335
|
+
return materializeResidentEntrySync(entry, stores, cache, "placeholder");
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
function materializeResidentEntriesForPersistenceSync<T extends FileEntry | SessionEntry>(
|
|
1339
|
+
entries: T[],
|
|
1340
|
+
stores: ResidentBlobStores,
|
|
1341
|
+
): T[] {
|
|
1342
|
+
const cache = new Map<string, string>();
|
|
1343
|
+
return entries.map(entry => materializeResidentEntryForPersistenceSync(entry, stores, cache));
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
export function residentBlobSentinelForTests(kind: ResidentBlobKind, ref: string): ResidentBlobSentinel {
|
|
1347
|
+
return residentBlobSentinel(kind, ref);
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
export function materializeResidentEntriesForPersistenceForTests<T>(
|
|
1351
|
+
entries: T[],
|
|
1352
|
+
textStore: BlobStore,
|
|
1353
|
+
imageStore: BlobStore = textStore,
|
|
1354
|
+
): T[] {
|
|
1355
|
+
return materializeResidentEntriesForPersistenceSync(entries as Array<T & FileEntry>, {
|
|
1356
|
+
textStore,
|
|
1357
|
+
imageStore,
|
|
1358
|
+
}) as T[];
|
|
1359
|
+
}
|
|
1311
1360
|
function cloneJsonSemantic<T>(value: T): T {
|
|
1312
1361
|
if (value === null || value === undefined || typeof value !== "object") return value;
|
|
1313
1362
|
if (Array.isArray(value)) return value.map(item => cloneJsonSemantic(item)) as T;
|
|
@@ -2109,7 +2158,7 @@ export class SessionManager {
|
|
|
2109
2158
|
#labelRevision = 0;
|
|
2110
2159
|
#replayMetadataRevision = 0;
|
|
2111
2160
|
#materializedEntriesRevision = -1;
|
|
2112
|
-
#materializedEntriesCache:
|
|
2161
|
+
#materializedEntriesCache: SessionEntry[] | undefined;
|
|
2113
2162
|
#sessionContextCache: WeakRef<SessionContext> | undefined;
|
|
2114
2163
|
#sessionContextEntryRevision = -1;
|
|
2115
2164
|
#sessionContextLeafRevision = -1;
|
|
@@ -2817,7 +2866,7 @@ export class SessionManager {
|
|
|
2817
2866
|
await this.#queuePersistTask(async () => {
|
|
2818
2867
|
await this.#closePersistWriterInternal();
|
|
2819
2868
|
const entries = await Promise.all(
|
|
2820
|
-
|
|
2869
|
+
materializeResidentEntriesForPersistenceSync(this.#fileEntries, this.#residentBlobStores()).map(entry =>
|
|
2821
2870
|
prepareEntryForPersistence(entry, this.#blobStore),
|
|
2822
2871
|
),
|
|
2823
2872
|
);
|
|
@@ -2831,8 +2880,8 @@ export class SessionManager {
|
|
|
2831
2880
|
#rewriteFileSync(): void {
|
|
2832
2881
|
if (!this.persist || !this.#sessionFile) return;
|
|
2833
2882
|
this.#closePersistWriterInternalSync();
|
|
2834
|
-
const entries =
|
|
2835
|
-
prepareEntryForPersistenceSync(entry, this.#blobStore),
|
|
2883
|
+
const entries = materializeResidentEntriesForPersistenceSync(this.#fileEntries, this.#residentBlobStores()).map(
|
|
2884
|
+
entry => prepareEntryForPersistenceSync(entry, this.#blobStore),
|
|
2836
2885
|
);
|
|
2837
2886
|
this.#writeEntriesAtomicallySync(entries);
|
|
2838
2887
|
this.#needsFullRewriteOnNextPersist = false;
|
|
@@ -3139,7 +3188,11 @@ export class SessionManager {
|
|
|
3139
3188
|
this.#rewriteFile().catch(() => {});
|
|
3140
3189
|
return;
|
|
3141
3190
|
}
|
|
3142
|
-
const materializedEntry =
|
|
3191
|
+
const materializedEntry = materializeResidentEntryForPersistenceSync(
|
|
3192
|
+
entry,
|
|
3193
|
+
this.#residentBlobStores(),
|
|
3194
|
+
new Map(),
|
|
3195
|
+
);
|
|
3143
3196
|
const persistedEntry = prepareEntryForPersistenceSync(materializedEntry, this.#blobStore);
|
|
3144
3197
|
writer.writeSync(persistedEntry);
|
|
3145
3198
|
} catch (err) {
|
|
@@ -3608,15 +3661,14 @@ export class SessionManager {
|
|
|
3608
3661
|
* change the leaf pointer. Entries cannot be modified or deleted.
|
|
3609
3662
|
*/
|
|
3610
3663
|
#getMaterializedEntriesInternal(): SessionEntry[] {
|
|
3611
|
-
if (this.#materializedEntriesRevision === this.#entryRevision) {
|
|
3612
|
-
|
|
3613
|
-
if (cached) return cached;
|
|
3664
|
+
if (this.#materializedEntriesRevision === this.#entryRevision && this.#materializedEntriesCache) {
|
|
3665
|
+
return this.#materializedEntriesCache;
|
|
3614
3666
|
}
|
|
3615
3667
|
const resolvedTextBlobCache = new Map<string, string>();
|
|
3616
3668
|
const materializedEntries = this.#fileEntries
|
|
3617
3669
|
.filter((e): e is SessionEntry => e.type !== "session")
|
|
3618
3670
|
.map(entry => materializeResidentEntrySync(entry, this.#residentBlobStores(), resolvedTextBlobCache));
|
|
3619
|
-
this.#materializedEntriesCache =
|
|
3671
|
+
this.#materializedEntriesCache = materializedEntries;
|
|
3620
3672
|
this.#materializedEntriesRevision = this.#entryRevision;
|
|
3621
3673
|
return materializedEntries;
|
|
3622
3674
|
}
|
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Discover and import existing Claude Code / Codex CLI credentials.
|
|
3
|
+
*
|
|
4
|
+
* This is the testable core behind `gjc setup credentials` (CLI, primary entry)
|
|
5
|
+
* and the TUI provider-onboarding "import existing credentials" action. It never
|
|
6
|
+
* prints or returns raw tokens: callers receive redacted summaries plus opaque
|
|
7
|
+
* {@link AuthCredential} payloads that go straight into the store.
|
|
8
|
+
*
|
|
9
|
+
* Sources:
|
|
10
|
+
* - Claude Code: `~/.claude/.credentials.json` (Linux/WSL/Windows native),
|
|
11
|
+
* the macOS Keychain (`Claude Code-credentials`), and env vars.
|
|
12
|
+
* - Codex CLI: `~/.codex/auth.json` (OAuth `tokens` block or stored
|
|
13
|
+
* `OPENAI_API_KEY`), and env vars.
|
|
14
|
+
*/
|
|
15
|
+
import * as fs from "node:fs/promises";
|
|
16
|
+
import * as os from "node:os";
|
|
17
|
+
import * as path from "node:path";
|
|
18
|
+
import type { AuthCredential, OAuthCredential } from "@gajae-code/ai";
|
|
19
|
+
import { isEnoent } from "@gajae-code/utils";
|
|
20
|
+
import { redactSecret } from "./provider-onboarding";
|
|
21
|
+
|
|
22
|
+
/** gjc provider ids that external credentials map onto. */
|
|
23
|
+
export type ExternalProvider = "anthropic" | "openai-codex";
|
|
24
|
+
|
|
25
|
+
/** Where a discovered credential came from. */
|
|
26
|
+
export type CredentialOrigin = "claude-code-file" | "claude-code-keychain" | "codex-file";
|
|
27
|
+
|
|
28
|
+
/** Human labels for providers, used in redacted summaries. */
|
|
29
|
+
export const EXTERNAL_PROVIDER_LABELS: Record<ExternalProvider, string> = {
|
|
30
|
+
anthropic: "Claude (Anthropic)",
|
|
31
|
+
"openai-codex": "Codex (ChatGPT)",
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** A credential that can be safely imported into gjc's store. */
|
|
35
|
+
export interface ImportableCredential {
|
|
36
|
+
provider: ExternalProvider;
|
|
37
|
+
origin: CredentialOrigin;
|
|
38
|
+
/** Redacted, human-readable description of where this came from. */
|
|
39
|
+
source: string;
|
|
40
|
+
kind: AuthCredential["type"];
|
|
41
|
+
identity?: { email?: string; accountId?: string };
|
|
42
|
+
/** Epoch-ms expiry for OAuth credentials, when known. */
|
|
43
|
+
expiresAt?: number;
|
|
44
|
+
/** Redacted access token / API key — safe to display. */
|
|
45
|
+
redactedToken: string;
|
|
46
|
+
/** Opaque credential payload. Never include this in any summary output. */
|
|
47
|
+
credential: AuthCredential;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A source that was found but could not be imported. */
|
|
51
|
+
export interface SkippedCredential {
|
|
52
|
+
origin: CredentialOrigin;
|
|
53
|
+
source: string;
|
|
54
|
+
reason: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Ambient environment-backed auth that is already usable without import. */
|
|
58
|
+
export interface EnvironmentCredentialHint {
|
|
59
|
+
provider: ExternalProvider;
|
|
60
|
+
variable: string;
|
|
61
|
+
redactedValue: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface CredentialDiscoveryResult {
|
|
65
|
+
importable: ImportableCredential[];
|
|
66
|
+
skipped: SkippedCredential[];
|
|
67
|
+
environment: EnvironmentCredentialHint[];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface DiscoveryOptions {
|
|
71
|
+
/** Override the home directory (defaults to `os.homedir()`). */
|
|
72
|
+
homeDir?: string;
|
|
73
|
+
/** Override the environment (defaults to `process.env`). */
|
|
74
|
+
env?: Record<string, string | undefined>;
|
|
75
|
+
/** Override the platform (defaults to `process.platform`). */
|
|
76
|
+
platform?: NodeJS.Platform;
|
|
77
|
+
/**
|
|
78
|
+
* Reader for the macOS Keychain `Claude Code-credentials` entry. Defaults to
|
|
79
|
+
* shelling out to `security`; injected in tests. Returns the raw JSON string,
|
|
80
|
+
* or `null` when no entry exists.
|
|
81
|
+
*/
|
|
82
|
+
readClaudeKeychain?: () => Promise<string | null>;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export type CredentialUpserter = (provider: string, credential: AuthCredential) => unknown | Promise<unknown>;
|
|
86
|
+
|
|
87
|
+
export interface ImportSummary {
|
|
88
|
+
imported: ImportableCredential[];
|
|
89
|
+
failed: Array<{ credential: ImportableCredential; error: string }>;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ─── Source-file shapes ──────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
interface ClaudeCredentialsFile {
|
|
95
|
+
claudeAiOauth?: {
|
|
96
|
+
accessToken?: unknown;
|
|
97
|
+
refreshToken?: unknown;
|
|
98
|
+
expiresAt?: unknown;
|
|
99
|
+
scopes?: unknown;
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
interface CodexAuthFile {
|
|
104
|
+
OPENAI_API_KEY?: unknown;
|
|
105
|
+
tokens?: {
|
|
106
|
+
id_token?: unknown;
|
|
107
|
+
access_token?: unknown;
|
|
108
|
+
refresh_token?: unknown;
|
|
109
|
+
account_id?: unknown;
|
|
110
|
+
};
|
|
111
|
+
last_refresh?: unknown;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const ANTHROPIC_ENV_KEYS = ["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"] as const;
|
|
115
|
+
const OPENAI_ENV_KEYS = ["OPENAI_API_KEY"] as const;
|
|
116
|
+
|
|
117
|
+
// ─── JWT helpers (best-effort identity/expiry extraction) ────────────────────
|
|
118
|
+
|
|
119
|
+
const OPENAI_AUTH_CLAIM = "https://api.openai.com/auth";
|
|
120
|
+
const OPENAI_PROFILE_CLAIM = "https://api.openai.com/profile";
|
|
121
|
+
|
|
122
|
+
function decodeJwtPayload(token: string): Record<string, unknown> | null {
|
|
123
|
+
try {
|
|
124
|
+
const parts = token.split(".");
|
|
125
|
+
if (parts.length !== 3) return null;
|
|
126
|
+
const payload = parts[1] ?? "";
|
|
127
|
+
const decoded = Buffer.from(payload, "base64url").toString("utf-8");
|
|
128
|
+
const parsed = JSON.parse(decoded) as unknown;
|
|
129
|
+
return typeof parsed === "object" && parsed !== null ? (parsed as Record<string, unknown>) : null;
|
|
130
|
+
} catch {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function nonEmptyString(value: unknown): string | undefined {
|
|
136
|
+
return typeof value === "string" && value.trim().length > 0 ? value : undefined;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Build a user-safe `skipped.reason` for a failed credential source.
|
|
141
|
+
*
|
|
142
|
+
* NEVER include the raw exception message: JSON parse errors and filesystem
|
|
143
|
+
* errors can echo back file contents (including bare token-like substrings) or
|
|
144
|
+
* other sensitive material, which then surfaces through CLI text/JSON and TUI
|
|
145
|
+
* discovery summaries. We expose only a generic phrase plus a non-sensitive
|
|
146
|
+
* error class (e.g. `SyntaxError`) or a standard Node syscall code (e.g.
|
|
147
|
+
* `EACCES`), both of which come from fixed, non-secret vocabularies.
|
|
148
|
+
*/
|
|
149
|
+
function sanitizedFailureReason(
|
|
150
|
+
base: "malformed credential file" | "unreadable credential file",
|
|
151
|
+
err: unknown,
|
|
152
|
+
): string {
|
|
153
|
+
if (!(err instanceof Error)) return base;
|
|
154
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
155
|
+
const detail = typeof code === "string" && /^[A-Z][A-Z0-9_]*$/.test(code) ? code : err.constructor.name;
|
|
156
|
+
return detail ? `${base} (${detail})` : base;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ─── Claude Code discovery ───────────────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
function parseClaudeCredentials(
|
|
162
|
+
raw: string,
|
|
163
|
+
origin: CredentialOrigin,
|
|
164
|
+
source: string,
|
|
165
|
+
): ImportableCredential | SkippedCredential {
|
|
166
|
+
let parsed: unknown;
|
|
167
|
+
try {
|
|
168
|
+
parsed = JSON.parse(raw) as ClaudeCredentialsFile;
|
|
169
|
+
} catch (err) {
|
|
170
|
+
return { origin, source, reason: sanitizedFailureReason("malformed credential file", err) };
|
|
171
|
+
}
|
|
172
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
173
|
+
return { origin, source, reason: "unsupported shape (root is not an object)" };
|
|
174
|
+
}
|
|
175
|
+
const credentials = parsed as ClaudeCredentialsFile;
|
|
176
|
+
const oauth = credentials.claudeAiOauth;
|
|
177
|
+
if (typeof oauth !== "object" || oauth === null) {
|
|
178
|
+
return { origin, source, reason: "missing claudeAiOauth block (unsupported shape)" };
|
|
179
|
+
}
|
|
180
|
+
const access = nonEmptyString(oauth.accessToken);
|
|
181
|
+
const refresh = nonEmptyString(oauth.refreshToken);
|
|
182
|
+
if (!access || !refresh) {
|
|
183
|
+
return { origin, source, reason: "missing accessToken or refreshToken" };
|
|
184
|
+
}
|
|
185
|
+
const expiresAt =
|
|
186
|
+
typeof oauth.expiresAt === "number" && Number.isFinite(oauth.expiresAt) ? oauth.expiresAt : undefined;
|
|
187
|
+
const credential: OAuthCredential = {
|
|
188
|
+
type: "oauth",
|
|
189
|
+
access,
|
|
190
|
+
refresh,
|
|
191
|
+
expires: expiresAt ?? Date.now(),
|
|
192
|
+
};
|
|
193
|
+
return {
|
|
194
|
+
provider: "anthropic",
|
|
195
|
+
origin,
|
|
196
|
+
source,
|
|
197
|
+
kind: "oauth",
|
|
198
|
+
expiresAt,
|
|
199
|
+
redactedToken: redactSecret(access),
|
|
200
|
+
credential,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function discoverClaudeCode(
|
|
205
|
+
opts: Required<Pick<DiscoveryOptions, "homeDir" | "platform">> & Pick<DiscoveryOptions, "readClaudeKeychain">,
|
|
206
|
+
result: CredentialDiscoveryResult,
|
|
207
|
+
): Promise<void> {
|
|
208
|
+
const filePath = path.join(opts.homeDir, ".claude", ".credentials.json");
|
|
209
|
+
const displayPath = `~/.claude/.credentials.json`;
|
|
210
|
+
let fileRaw: string | null = null;
|
|
211
|
+
try {
|
|
212
|
+
fileRaw = await fs.readFile(filePath, "utf-8");
|
|
213
|
+
} catch (err) {
|
|
214
|
+
if (!isEnoent(err)) {
|
|
215
|
+
result.skipped.push({
|
|
216
|
+
origin: "claude-code-file",
|
|
217
|
+
source: `Claude Code (${displayPath})`,
|
|
218
|
+
reason: sanitizedFailureReason("unreadable credential file", err),
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
if (fileRaw !== null) {
|
|
223
|
+
const outcome = parseClaudeCredentials(fileRaw, "claude-code-file", `Claude Code (${displayPath})`);
|
|
224
|
+
pushOutcome(result, outcome);
|
|
225
|
+
} else if (opts.platform === "darwin") {
|
|
226
|
+
const reader = opts.readClaudeKeychain ?? defaultClaudeKeychainReader;
|
|
227
|
+
let keychainRaw: string | null = null;
|
|
228
|
+
try {
|
|
229
|
+
keychainRaw = await reader();
|
|
230
|
+
} catch (err) {
|
|
231
|
+
result.skipped.push({
|
|
232
|
+
origin: "claude-code-keychain",
|
|
233
|
+
source: "Claude Code (macOS Keychain)",
|
|
234
|
+
reason: sanitizedFailureReason("unreadable credential file", err),
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
if (keychainRaw !== null && keychainRaw.trim().length > 0) {
|
|
238
|
+
const outcome = parseClaudeCredentials(keychainRaw, "claude-code-keychain", "Claude Code (macOS Keychain)");
|
|
239
|
+
pushOutcome(result, outcome);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function defaultClaudeKeychainReader(): Promise<string | null> {
|
|
245
|
+
const { $ } = await import("bun");
|
|
246
|
+
const proc = await $`security find-generic-password -s ${"Claude Code-credentials"} -w`.quiet().nothrow();
|
|
247
|
+
if (proc.exitCode !== 0) return null;
|
|
248
|
+
const out = proc.stdout.toString().trim();
|
|
249
|
+
return out.length > 0 ? out : null;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ─── Codex discovery ─────────────────────────────────────────────────────────
|
|
253
|
+
|
|
254
|
+
function parseCodexAuth(raw: string, source: string): ImportableCredential | SkippedCredential {
|
|
255
|
+
let parsed: unknown;
|
|
256
|
+
try {
|
|
257
|
+
parsed = JSON.parse(raw) as CodexAuthFile;
|
|
258
|
+
} catch (err) {
|
|
259
|
+
return { origin: "codex-file", source, reason: sanitizedFailureReason("malformed credential file", err) };
|
|
260
|
+
}
|
|
261
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
262
|
+
return { origin: "codex-file", source, reason: "unsupported shape (root is not an object)" };
|
|
263
|
+
}
|
|
264
|
+
const auth = parsed as CodexAuthFile;
|
|
265
|
+
const tokens = auth.tokens;
|
|
266
|
+
const access = nonEmptyString(tokens?.access_token);
|
|
267
|
+
const refresh = nonEmptyString(tokens?.refresh_token);
|
|
268
|
+
if (access && refresh) {
|
|
269
|
+
const accessPayload = decodeJwtPayload(access);
|
|
270
|
+
const idPayload = nonEmptyString(tokens?.id_token) ? decodeJwtPayload(tokens?.id_token as string) : null;
|
|
271
|
+
const accountId =
|
|
272
|
+
nonEmptyString(tokens?.account_id) ??
|
|
273
|
+
nonEmptyString((accessPayload?.[OPENAI_AUTH_CLAIM] as { chatgpt_account_id?: unknown })?.chatgpt_account_id) ??
|
|
274
|
+
nonEmptyString((idPayload?.[OPENAI_AUTH_CLAIM] as { chatgpt_account_id?: unknown })?.chatgpt_account_id);
|
|
275
|
+
const email =
|
|
276
|
+
nonEmptyString((accessPayload?.[OPENAI_PROFILE_CLAIM] as { email?: unknown })?.email) ??
|
|
277
|
+
nonEmptyString(idPayload?.email)?.toLowerCase();
|
|
278
|
+
const expSeconds = typeof accessPayload?.exp === "number" ? accessPayload.exp : undefined;
|
|
279
|
+
const expiresAt = expSeconds !== undefined ? expSeconds * 1000 : undefined;
|
|
280
|
+
const credential: OAuthCredential = {
|
|
281
|
+
type: "oauth",
|
|
282
|
+
access,
|
|
283
|
+
refresh,
|
|
284
|
+
expires: expiresAt ?? Date.now(),
|
|
285
|
+
...(accountId ? { accountId } : {}),
|
|
286
|
+
...(email ? { email } : {}),
|
|
287
|
+
};
|
|
288
|
+
const identity =
|
|
289
|
+
accountId || email ? { ...(email ? { email } : {}), ...(accountId ? { accountId } : {}) } : undefined;
|
|
290
|
+
return {
|
|
291
|
+
provider: "openai-codex",
|
|
292
|
+
origin: "codex-file",
|
|
293
|
+
source,
|
|
294
|
+
kind: "oauth",
|
|
295
|
+
...(identity ? { identity } : {}),
|
|
296
|
+
expiresAt,
|
|
297
|
+
redactedToken: redactSecret(access),
|
|
298
|
+
credential,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
if (tokens && (tokens.access_token !== undefined || tokens.refresh_token !== undefined)) {
|
|
302
|
+
return {
|
|
303
|
+
origin: "codex-file",
|
|
304
|
+
source,
|
|
305
|
+
reason: "incomplete OAuth tokens (missing access_token or refresh_token)",
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
const apiKey = nonEmptyString(auth.OPENAI_API_KEY);
|
|
309
|
+
if (apiKey) {
|
|
310
|
+
return {
|
|
311
|
+
provider: "openai-codex",
|
|
312
|
+
origin: "codex-file",
|
|
313
|
+
source,
|
|
314
|
+
kind: "api_key",
|
|
315
|
+
redactedToken: redactSecret(apiKey),
|
|
316
|
+
credential: { type: "api_key", key: apiKey },
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
return { origin: "codex-file", source, reason: "no OAuth tokens or OPENAI_API_KEY present (unsupported shape)" };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async function discoverCodex(homeDir: string, result: CredentialDiscoveryResult): Promise<void> {
|
|
323
|
+
const filePath = path.join(homeDir, ".codex", "auth.json");
|
|
324
|
+
const displayPath = "~/.codex/auth.json";
|
|
325
|
+
let raw: string | null = null;
|
|
326
|
+
try {
|
|
327
|
+
raw = await fs.readFile(filePath, "utf-8");
|
|
328
|
+
} catch (err) {
|
|
329
|
+
if (!isEnoent(err)) {
|
|
330
|
+
result.skipped.push({
|
|
331
|
+
origin: "codex-file",
|
|
332
|
+
source: `Codex CLI (${displayPath})`,
|
|
333
|
+
reason: sanitizedFailureReason("unreadable credential file", err),
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
pushOutcome(result, parseCodexAuth(raw, `Codex CLI (${displayPath})`));
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// ─── Environment hints ───────────────────────────────────────────────────────
|
|
342
|
+
|
|
343
|
+
function discoverEnvironment(env: Record<string, string | undefined>, result: CredentialDiscoveryResult): void {
|
|
344
|
+
for (const variable of ANTHROPIC_ENV_KEYS) {
|
|
345
|
+
const value = nonEmptyString(env[variable]);
|
|
346
|
+
if (value) {
|
|
347
|
+
result.environment.push({ provider: "anthropic", variable, redactedValue: redactSecret(value) });
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
for (const variable of OPENAI_ENV_KEYS) {
|
|
351
|
+
const value = nonEmptyString(env[variable]);
|
|
352
|
+
if (value) {
|
|
353
|
+
result.environment.push({ provider: "openai-codex", variable, redactedValue: redactSecret(value) });
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// ─── Public API ──────────────────────────────────────────────────────────────
|
|
359
|
+
|
|
360
|
+
function pushOutcome(result: CredentialDiscoveryResult, outcome: ImportableCredential | SkippedCredential): void {
|
|
361
|
+
if ("reason" in outcome) result.skipped.push(outcome);
|
|
362
|
+
else result.importable.push(outcome);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Discover Claude Code and Codex CLI credentials across files, the macOS
|
|
367
|
+
* Keychain, and environment variables. Never throws for individual unreadable or
|
|
368
|
+
* malformed sources — those land in {@link CredentialDiscoveryResult.skipped}.
|
|
369
|
+
*/
|
|
370
|
+
export async function discoverExternalCredentials(options: DiscoveryOptions = {}): Promise<CredentialDiscoveryResult> {
|
|
371
|
+
const homeDir = options.homeDir ?? os.homedir();
|
|
372
|
+
const env = options.env ?? process.env;
|
|
373
|
+
const platform = options.platform ?? process.platform;
|
|
374
|
+
const result: CredentialDiscoveryResult = { importable: [], skipped: [], environment: [] };
|
|
375
|
+
await discoverClaudeCode({ homeDir, platform, readClaudeKeychain: options.readClaudeKeychain }, result);
|
|
376
|
+
await discoverCodex(homeDir, result);
|
|
377
|
+
discoverEnvironment(env, result);
|
|
378
|
+
return result;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Redacted one-line summary of an importable credential. Never includes secrets. */
|
|
382
|
+
export function formatCredentialSummary(credential: ImportableCredential): string {
|
|
383
|
+
const provider = EXTERNAL_PROVIDER_LABELS[credential.provider];
|
|
384
|
+
const kind = credential.kind === "oauth" ? "OAuth" : "API key";
|
|
385
|
+
const identity = credential.identity?.email ?? credential.identity?.accountId;
|
|
386
|
+
const identityPart = identity ? ` ${identity}` : "";
|
|
387
|
+
let expiry = "";
|
|
388
|
+
if (credential.kind === "oauth" && credential.expiresAt !== undefined) {
|
|
389
|
+
expiry = credential.expiresAt < Date.now() ? " [expired]" : "";
|
|
390
|
+
}
|
|
391
|
+
return `${provider} · ${kind}${identityPart} · token ${credential.redactedToken}${expiry} (from ${credential.source})`;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** Redacted summary lines for an entire discovery result. Never includes secrets. */
|
|
395
|
+
export function formatDiscoverySummary(result: CredentialDiscoveryResult): string[] {
|
|
396
|
+
const lines: string[] = [];
|
|
397
|
+
for (const credential of result.importable) {
|
|
398
|
+
lines.push(`import ${formatCredentialSummary(credential)}`);
|
|
399
|
+
}
|
|
400
|
+
for (const skip of result.skipped) {
|
|
401
|
+
lines.push(`skip ${skip.source}: ${skip.reason}`);
|
|
402
|
+
}
|
|
403
|
+
for (const hint of result.environment) {
|
|
404
|
+
lines.push(
|
|
405
|
+
`env ${EXTERNAL_PROVIDER_LABELS[hint.provider]} · ${hint.variable}=${hint.redactedValue} (already active via environment)`,
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
return lines;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Persist discovered credentials via `upsert`. Each credential is imported
|
|
413
|
+
* independently; a failure on one is recorded without aborting the rest.
|
|
414
|
+
*/
|
|
415
|
+
export async function importCredentials(
|
|
416
|
+
credentials: readonly ImportableCredential[],
|
|
417
|
+
upsert: CredentialUpserter,
|
|
418
|
+
): Promise<ImportSummary> {
|
|
419
|
+
const summary: ImportSummary = { imported: [], failed: [] };
|
|
420
|
+
for (const credential of credentials) {
|
|
421
|
+
try {
|
|
422
|
+
await upsert(credential.provider, credential.credential);
|
|
423
|
+
summary.imported.push(credential);
|
|
424
|
+
} catch (err) {
|
|
425
|
+
summary.failed.push({ credential, error: err instanceof Error ? err.message : String(err) });
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
return summary;
|
|
429
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as path from "node:path";
|
|
2
2
|
import type { AgentTool } from "@gajae-code/agent-core";
|
|
3
|
+
import { logger } from "@gajae-code/utils";
|
|
3
4
|
import { expandApplyPatchToEntries } from "../edit/modes/apply-patch";
|
|
4
5
|
import { ModeStateSchema } from "../gjc-runtime/state-schema";
|
|
5
6
|
import { LocalProtocolHandler, resolveLocalUrlToPath } from "../internal-urls/local-protocol";
|
|
@@ -78,7 +79,7 @@ function modeStatePath(cwd: string, skill: string, sessionId?: string): string {
|
|
|
78
79
|
}
|
|
79
80
|
|
|
80
81
|
function warnInvalidModeState(filePath: string, error: string): void {
|
|
81
|
-
|
|
82
|
+
logger.warn(`gjc skill-state: invalid mode-state at ${filePath}: ${error}`);
|
|
82
83
|
}
|
|
83
84
|
|
|
84
85
|
async function readValidatedModeState(filePath: string): Promise<ModeState | null> {
|
package/src/task/executor.ts
CHANGED
|
@@ -482,11 +482,17 @@ function getUsageTokens(usage: unknown): number {
|
|
|
482
482
|
return firstNumberField(record, ["totalTokens", "total_tokens"]) ?? 0;
|
|
483
483
|
}
|
|
484
484
|
|
|
485
|
-
function createSubagentSettings(baseSettings: Settings): Settings {
|
|
485
|
+
export function createSubagentSettings(baseSettings: Settings): Settings {
|
|
486
486
|
const snapshot: Partial<Record<SettingPath, unknown>> = {};
|
|
487
487
|
for (const key of Object.keys(SETTINGS_SCHEMA) as SettingPath[]) {
|
|
488
488
|
snapshot[key] = baseSettings.get(key);
|
|
489
489
|
}
|
|
490
|
+
// Subagent-scoped service-tier override: "inherit" keeps the snapshotted main
|
|
491
|
+
// session tier; any explicit value applies only to subagent sessions.
|
|
492
|
+
const taskServiceTier = baseSettings.get("task.serviceTier");
|
|
493
|
+
if (taskServiceTier !== "inherit") {
|
|
494
|
+
snapshot.serviceTier = taskServiceTier;
|
|
495
|
+
}
|
|
490
496
|
return Settings.isolated({
|
|
491
497
|
...snapshot,
|
|
492
498
|
"async.enabled": false,
|