@cjhyy/code-shell-core 0.7.0 → 0.7.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.
Files changed (118) hide show
  1. package/dist/arena/arena.d.ts +2 -0
  2. package/dist/arena/arena.js +30 -1
  3. package/dist/arena/phases/adjudication.d.ts +2 -1
  4. package/dist/arena/phases/adjudication.js +2 -1
  5. package/dist/arena/phases/build-consensus.d.ts +2 -1
  6. package/dist/arena/phases/build-consensus.js +3 -1
  7. package/dist/arena/phases/cross-review.d.ts +3 -1
  8. package/dist/arena/phases/cross-review.js +8 -2
  9. package/dist/arena/phases/debate-rounds.d.ts +2 -1
  10. package/dist/arena/phases/debate-rounds.js +4 -2
  11. package/dist/arena/phases/participant-research.d.ts +2 -1
  12. package/dist/arena/phases/participant-research.js +3 -1
  13. package/dist/arena/phases/planning-detail-expansion.d.ts +2 -1
  14. package/dist/arena/phases/planning-detail-expansion.js +2 -1
  15. package/dist/arena/planner.d.ts +2 -1
  16. package/dist/arena/planner.js +2 -1
  17. package/dist/arena/types.d.ts +5 -1
  18. package/dist/cli/agent-server-tcp.js +13 -2
  19. package/dist/context/manager.d.ts +3 -3
  20. package/dist/context/manager.js +6 -6
  21. package/dist/context/token-counter.js +5 -3
  22. package/dist/context/tool-result-storage.d.ts +6 -0
  23. package/dist/context/tool-result-storage.js +25 -4
  24. package/dist/credentials/access.d.ts +10 -1
  25. package/dist/credentials/access.js +76 -2
  26. package/dist/credentials/index.d.ts +3 -2
  27. package/dist/credentials/index.js +2 -1
  28. package/dist/credentials/oauth.d.ts +6 -1
  29. package/dist/credentials/oauth.js +66 -1
  30. package/dist/credentials/store.d.ts +1 -1
  31. package/dist/credentials/store.js +16 -7
  32. package/dist/credentials/types.d.ts +38 -1
  33. package/dist/credentials/types.js +16 -1
  34. package/dist/engine/engine.d.ts +61 -32
  35. package/dist/engine/engine.js +289 -55
  36. package/dist/engine/goal.d.ts +5 -3
  37. package/dist/engine/session-title.d.ts +2 -1
  38. package/dist/engine/session-title.js +4 -1
  39. package/dist/engine/turn-loop.d.ts +18 -0
  40. package/dist/engine/turn-loop.js +202 -27
  41. package/dist/engine/types.d.ts +6 -2
  42. package/dist/git/worktree/crud.d.ts +3 -0
  43. package/dist/git/worktree/crud.js +32 -3
  44. package/dist/git/worktree/git-exec.d.ts +2 -2
  45. package/dist/git/worktree/git-exec.js +47 -11
  46. package/dist/git/worktree/query.d.ts +8 -7
  47. package/dist/git/worktree/query.js +27 -20
  48. package/dist/hooks/events.d.ts +3 -0
  49. package/dist/hooks/events.js +0 -3
  50. package/dist/hooks/goal-stop-hook.d.ts +14 -4
  51. package/dist/hooks/goal-stop-hook.js +626 -71
  52. package/dist/hooks/registry.js +3 -0
  53. package/dist/hooks/shell-runner.d.ts +12 -1
  54. package/dist/hooks/shell-runner.js +160 -9
  55. package/dist/index.d.ts +7 -6
  56. package/dist/index.js +6 -5
  57. package/dist/llm/client-base.js +12 -10
  58. package/dist/llm/types.d.ts +12 -5
  59. package/dist/plugins/pluginCommandHook.d.ts +4 -4
  60. package/dist/plugins/pluginCommandHook.js +111 -13
  61. package/dist/protocol/chat-session-manager.d.ts +13 -2
  62. package/dist/protocol/chat-session-manager.js +90 -18
  63. package/dist/protocol/chat-session.d.ts +12 -0
  64. package/dist/protocol/chat-session.js +30 -5
  65. package/dist/protocol/client.d.ts +5 -2
  66. package/dist/protocol/client.js +22 -1
  67. package/dist/protocol/server.d.ts +25 -11
  68. package/dist/protocol/server.js +290 -72
  69. package/dist/protocol/types.d.ts +34 -2
  70. package/dist/protocol/types.js +2 -0
  71. package/dist/services/dream-consolidation.d.ts +3 -0
  72. package/dist/services/dream-consolidation.js +4 -1
  73. package/dist/services/index.d.ts +1 -1
  74. package/dist/services/index.js +1 -1
  75. package/dist/services/oauth.d.ts +34 -10
  76. package/dist/services/oauth.js +233 -98
  77. package/dist/session/session-manager.d.ts +35 -6
  78. package/dist/session/session-manager.js +362 -26
  79. package/dist/session/transcript.d.ts +30 -1
  80. package/dist/session/transcript.js +119 -4
  81. package/dist/tool-system/builtin/agent.js +5 -1
  82. package/dist/tool-system/builtin/arena.js +1 -0
  83. package/dist/tool-system/builtin/background-jobs.d.ts +8 -5
  84. package/dist/tool-system/builtin/background-jobs.js +15 -11
  85. package/dist/tool-system/builtin/background-work.d.ts +6 -1
  86. package/dist/tool-system/builtin/background-work.js +5 -1
  87. package/dist/tool-system/builtin/bash.d.ts +3 -5
  88. package/dist/tool-system/builtin/bash.js +10 -5
  89. package/dist/tool-system/builtin/browser-tools.d.ts +2 -2
  90. package/dist/tool-system/builtin/edit.d.ts +2 -1
  91. package/dist/tool-system/builtin/edit.js +12 -4
  92. package/dist/tool-system/builtin/generate-video.d.ts +4 -0
  93. package/dist/tool-system/builtin/generate-video.js +138 -21
  94. package/dist/tool-system/builtin/glob.d.ts +2 -1
  95. package/dist/tool-system/builtin/glob.js +28 -3
  96. package/dist/tool-system/builtin/grep.d.ts +1 -0
  97. package/dist/tool-system/builtin/grep.js +82 -17
  98. package/dist/tool-system/builtin/index.d.ts +25 -11
  99. package/dist/tool-system/builtin/index.js +45 -2
  100. package/dist/tool-system/builtin/video-providers.d.ts +12 -15
  101. package/dist/tool-system/builtin/video-providers.js +1 -0
  102. package/dist/tool-system/builtin/view-image.d.ts +2 -2
  103. package/dist/tool-system/builtin/web-fetch.js +44 -3
  104. package/dist/tool-system/builtin/worktree.js +25 -7
  105. package/dist/tool-system/builtin/write.d.ts +2 -1
  106. package/dist/tool-system/builtin/write.js +14 -4
  107. package/dist/tool-system/context.d.ts +17 -5
  108. package/dist/tool-system/executor.js +24 -8
  109. package/dist/tool-system/mcp-manager.d.ts +6 -0
  110. package/dist/tool-system/mcp-manager.js +55 -5
  111. package/dist/tool-system/path-policy.d.ts +19 -0
  112. package/dist/tool-system/path-policy.js +62 -1
  113. package/dist/tool-system/permission.d.ts +43 -3
  114. package/dist/tool-system/permission.js +383 -30
  115. package/dist/tool-system/registry.d.ts +3 -2
  116. package/dist/tool-system/registry.js +52 -34
  117. package/dist/types.d.ts +15 -0
  118. package/package.json +1 -1
@@ -22,7 +22,8 @@
22
22
  * parallel Read results), persist the largest ones first until under.
23
23
  */
24
24
  import { mkdirSync, writeFileSync } from "node:fs";
25
- import { dirname, join } from "node:path";
25
+ import { createHash } from "node:crypto";
26
+ import { dirname, isAbsolute, join, relative, resolve, sep, win32 } from "node:path";
26
27
  import { logger } from "../logging/logger.js";
27
28
  // ─── Tunables ───────────────────────────────────────────────────────
28
29
  /** Per-result threshold. Larger than this → persist + replace. */
@@ -77,12 +78,32 @@ function ensureDir(dir) {
77
78
  }
78
79
  }
79
80
  /**
80
- * Write content to <dir>/<toolUseId>.txt. Idempotent: skips if the file
81
- * already exists (tool_use_id is a UUID, content is deterministic per id).
81
+ * Convert an externally supplied tool_use_id to a bounded internal filename.
82
+ * The original id remains the key in ContentReplacementState/onPersist; it is
83
+ * never used as a path component.
82
84
  */
85
+ export function safeToolResultFilename(toolUseId) {
86
+ if (toolUseId.length > 256) {
87
+ throw new RangeError("tool_use_id exceeds the 256 character limit");
88
+ }
89
+ if (isAbsolute(toolUseId) ||
90
+ win32.isAbsolute(toolUseId) ||
91
+ toolUseId.includes("/") ||
92
+ toolUseId.includes("\\")) {
93
+ throw new Error("tool_use_id must not contain a path");
94
+ }
95
+ return `${createHash("sha256").update(toolUseId, "utf8").digest("hex")}.txt`;
96
+ }
97
+ /** Write content to the contained hashed path. Idempotent by original id. */
83
98
  function persistToFile(dir, toolUseId, content) {
99
+ const root = resolve(dir);
100
+ const filename = safeToolResultFilename(toolUseId);
101
+ const filepath = resolve(root, filename);
102
+ const rel = relative(root, filepath);
103
+ if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
104
+ throw new Error("tool_result path escaped its storage root");
105
+ }
84
106
  ensureDir(dir);
85
- const filepath = join(dir, `${toolUseId}.txt`);
86
107
  try {
87
108
  // 'wx' = fail if exists. Skipping on collision is correct: same id =
88
109
  // same content. Avoids re-writing the same bytes every turn.
@@ -1,4 +1,4 @@
1
- import type { Credential, CredentialType } from "./types.js";
1
+ import { type Credential, type CredentialType } from "./types.js";
2
2
  import type { SettingsScope } from "../settings/manager.js";
3
3
  import type { Transport } from "../protocol/transport.js";
4
4
  export type CredentialAccessScope = "full" | "project";
@@ -24,6 +24,15 @@ export interface CredentialAccess {
24
24
  scope: CredentialAccessScope;
25
25
  purpose: "use" | "mcp";
26
26
  }): Promise<string>;
27
+ /** Resolve only the bearer material needed by an MCP request. */
28
+ resolveOAuthAccess?(req: {
29
+ id: string;
30
+ scope: "full";
31
+ forceRefresh?: boolean;
32
+ }): Promise<{
33
+ accessToken: string;
34
+ expiresAt?: string;
35
+ }>;
27
36
  materializeCookie?(req: {
28
37
  cwd?: string;
29
38
  id: string;
@@ -2,9 +2,11 @@ import { existsSync, readdirSync, rmSync, statSync, writeFileSync } from "node:f
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
+ import { credentialAllowsEnvExposure, credentialSecretHint, } from "./types.js";
5
6
  import { CredentialStore } from "./store.js";
6
7
  import { formatNetscapeCookies, parseCookieJar } from "./cookie-jar.js";
7
- import { summarizeOAuthCredentialSecret } from "./oauth.js";
8
+ import { parseOAuthCredentialSecret, shouldRefreshOAuthCredential, summarizeOAuthCredentialSecret, } from "./oauth.js";
9
+ import { refreshToken } from "../services/oauth.js";
8
10
  const COOKIE_FILE_PREFIX = "codeshell-cred-cookie-";
9
11
  const COOKIE_FILE_MAX_AGE_MS = 30 * 60 * 1000;
10
12
  let defaultCredentialAccess = null;
@@ -85,6 +87,16 @@ export function createIpcCredentialAccess(transport) {
85
87
  throw new Error(`credential "${req.id}" is unavailable`);
86
88
  return result.value;
87
89
  },
90
+ async resolveOAuthAccess(req) {
91
+ const result = (await request("desktop/oauthAccessResolve", req));
92
+ if (typeof result?.accessToken !== "string") {
93
+ throw new Error(`oauth credential "${req.id}" requires login`);
94
+ }
95
+ return {
96
+ accessToken: result.accessToken,
97
+ expiresAt: typeof result.expiresAt === "string" ? result.expiresAt : undefined,
98
+ };
99
+ },
88
100
  async materializeCookie(req) {
89
101
  const result = (await request("desktop/credentialMaterializeCookie", req));
90
102
  if (typeof result?.cookiesFile !== "string" || typeof result.count !== "number") {
@@ -106,8 +118,9 @@ function toMetadata(cred) {
106
118
  const { secret: _secret, ...rest } = cred;
107
119
  return {
108
120
  ...rest,
121
+ ...(credentialAllowsEnvExposure(cred.type) ? {} : { exposeAsEnv: undefined }),
109
122
  hasSecret: available,
110
- secretHint: available ? (secret.length > 4 ? `****${secret.slice(-4)}` : "****") : undefined,
123
+ secretHint: available ? credentialSecretHint(cred.type, secret) : undefined,
111
124
  ...(cred.type === "oauth" ? { oauthStatus: summarizeOAuthCredentialSecret(secret) } : {}),
112
125
  };
113
126
  }
@@ -129,6 +142,8 @@ export const localCredentialAccess = {
129
142
  const out = {};
130
143
  const creds = storeFor(cwd).list(scope);
131
144
  for (const cred of creds) {
145
+ if (!credentialAllowsEnvExposure(cred.type))
146
+ continue;
132
147
  const name = cred.exposeAsEnv?.trim();
133
148
  if (name && isCredentialSecretAvailable(cred.secret))
134
149
  out[name] = cred.secret;
@@ -142,6 +157,9 @@ export const localCredentialAccess = {
142
157
  }
143
158
  return cred.secret;
144
159
  },
160
+ async resolveOAuthAccess(req) {
161
+ return resolveLocalOAuthAccess(req.id, Boolean(req.forceRefresh));
162
+ },
145
163
  async materializeCookie(req) {
146
164
  const cred = storeFor(req.cwd).resolve(req.id, req.scope);
147
165
  if (!cred || cred.type !== "cookie" || !isCredentialSecretAvailable(cred.secret)) {
@@ -150,6 +168,62 @@ export const localCredentialAccess = {
150
168
  return materializeCookieSecret(cred.id, cred.secret);
151
169
  },
152
170
  };
171
+ const localOAuthRefreshes = new Map();
172
+ async function resolveLocalOAuthAccess(id, forceRefresh) {
173
+ const store = new CredentialStore(undefined);
174
+ const cred = store.resolve(id, "full");
175
+ if (!cred || cred.type !== "oauth" || !isCredentialSecretAvailable(cred.secret)) {
176
+ throw new Error(`oauth credential "${id}" requires login`);
177
+ }
178
+ const parsed = parseOAuthCredentialSecret(cred.secret);
179
+ const decision = shouldRefreshOAuthCredential(parsed);
180
+ if (!forceRefresh && decision === "no") {
181
+ return { accessToken: parsed.accessToken, expiresAt: parsed.expiresAt };
182
+ }
183
+ if (!parsed.refreshToken || !parsed.tokenEndpoint || !parsed.clientId) {
184
+ throw new Error(`oauth credential "${id}" requires login`);
185
+ }
186
+ const inflight = localOAuthRefreshes.get(id);
187
+ if (inflight)
188
+ return inflight;
189
+ const pending = (async () => {
190
+ const latestCred = store.resolve(id, "full");
191
+ if (!latestCred || latestCred.type !== "oauth" || !latestCred.secret) {
192
+ throw new Error(`oauth credential "${id}" requires login`);
193
+ }
194
+ const latest = parseOAuthCredentialSecret(latestCred.secret);
195
+ if (!forceRefresh && shouldRefreshOAuthCredential(latest) === "no") {
196
+ return { accessToken: latest.accessToken, expiresAt: latest.expiresAt };
197
+ }
198
+ if (!latest.refreshToken || !latest.tokenEndpoint || !latest.clientId) {
199
+ throw new Error(`oauth credential "${id}" requires login`);
200
+ }
201
+ const tokens = await refreshToken({
202
+ clientId: latest.clientId,
203
+ clientSecret: latest.clientSecret,
204
+ tokenEndpoint: latest.tokenEndpoint,
205
+ authorizationEndpoint: "",
206
+ scopes: latest.scopes,
207
+ resource: latest.resource,
208
+ }, latest.refreshToken);
209
+ const next = {
210
+ ...latest,
211
+ accessToken: tokens.accessToken,
212
+ refreshToken: tokens.refreshToken ?? latest.refreshToken,
213
+ expiresAt: tokens.expiresAt ? new Date(tokens.expiresAt).toISOString() : undefined,
214
+ tokenType: tokens.tokenType,
215
+ scope: tokens.scope ?? latest.scope,
216
+ };
217
+ store.save("user", {
218
+ ...latestCred,
219
+ secret: JSON.stringify(next),
220
+ meta: { ...latestCred.meta, lastRefreshAt: new Date().toISOString() },
221
+ });
222
+ return { accessToken: next.accessToken, expiresAt: next.expiresAt };
223
+ })().finally(() => localOAuthRefreshes.delete(id));
224
+ localOAuthRefreshes.set(id, pending);
225
+ return pending;
226
+ }
153
227
  export function materializeCookieSecret(credentialId, secret) {
154
228
  const jar = parseCookieJar(secret);
155
229
  if (jar.length === 0)
@@ -2,8 +2,9 @@ export { CredentialStore } from "./store.js";
2
2
  export type { CredentialScope, MaskedCredential } from "./store.js";
3
3
  export { type EncryptionCipher, PlaintextCipher, setDefaultCredentialCipher, getDefaultCredentialCipher, } from "./cipher.js";
4
4
  export { getCredentialAccess, setDefaultCredentialAccess, createIpcCredentialAccess, localCredentialAccess, credentialAccessScope, isCredentialSecretAvailable, materializeCookieSecret, type CredentialAccess, type CredentialAccessScope, type CredentialMetadata, type CredentialSnapshot, type CredentialSnapshotEntry, } from "./access.js";
5
- export type { Credential, CredentialType, CredentialStoreFile, OAuthCredentialPublicStatus, OAuthCredentialSecret, } from "./types.js";
6
- export { buildOAuthRefreshRequest, isOAuthAccessTokenExpired, oauthCredentialStatus, parseOAuthCredentialSecret, summarizeOAuthCredentialSecret, type OAuthClockOptions, type OAuthRefreshHandler, type OAuthRefreshRequest, } from "./oauth.js";
5
+ export type { Credential, CredentialType, CredentialStoreFile, OAuthCredentialPublicStatus, OAuthCredentialSecret, OAuthTokenResponse, } from "./types.js";
6
+ export { credentialAllowsEnvExposure, credentialSecretHint } from "./types.js";
7
+ export { buildOAuthRefreshRequest, isOAuthAccessTokenExpired, oauthCredentialStatus, parseOAuthCredentialSecret, mergeOAuthTokenResponse, shouldRefreshOAuthCredential, summarizeOAuthCredentialSecret, type OAuthClockOptions, type OAuthRefreshHandler, type OAuthRefreshRequest, } from "./oauth.js";
7
8
  export { formatNetscapeCookies, parseCookieJar, type CookieLike } from "./cookie-jar.js";
8
9
  export { useCredentialToolDef, useCredentialToolDefFor, useCredentialTool, sweepStaleCredentialCookies, } from "./use-credential-tool.js";
9
10
  export { credentialUseGate } from "./use-gate.js";
@@ -1,7 +1,8 @@
1
1
  export { CredentialStore } from "./store.js";
2
2
  export { PlaintextCipher, setDefaultCredentialCipher, getDefaultCredentialCipher, } from "./cipher.js";
3
3
  export { getCredentialAccess, setDefaultCredentialAccess, createIpcCredentialAccess, localCredentialAccess, credentialAccessScope, isCredentialSecretAvailable, materializeCookieSecret, } from "./access.js";
4
- export { buildOAuthRefreshRequest, isOAuthAccessTokenExpired, oauthCredentialStatus, parseOAuthCredentialSecret, summarizeOAuthCredentialSecret, } from "./oauth.js";
4
+ export { credentialAllowsEnvExposure, credentialSecretHint } from "./types.js";
5
+ export { buildOAuthRefreshRequest, isOAuthAccessTokenExpired, oauthCredentialStatus, parseOAuthCredentialSecret, mergeOAuthTokenResponse, shouldRefreshOAuthCredential, summarizeOAuthCredentialSecret, } from "./oauth.js";
5
6
  export { formatNetscapeCookies, parseCookieJar } from "./cookie-jar.js";
6
7
  export { useCredentialToolDef, useCredentialToolDefFor, useCredentialTool, sweepStaleCredentialCookies, } from "./use-credential-tool.js";
7
8
  export { credentialUseGate } from "./use-gate.js";
@@ -1,4 +1,4 @@
1
- import type { OAuthCredentialPublicStatus, OAuthCredentialSecret } from "./types.js";
1
+ import type { OAuthCredentialPublicStatus, OAuthCredentialSecret, OAuthTokenResponse } from "./types.js";
2
2
  export interface OAuthClockOptions {
3
3
  now?: number | (() => number);
4
4
  skewMs?: number;
@@ -14,6 +14,11 @@ export interface OAuthRefreshRequest {
14
14
  }
15
15
  export type OAuthRefreshHandler = (req: OAuthRefreshRequest) => Promise<OAuthCredentialSecret>;
16
16
  export declare function parseOAuthCredentialSecret(secret: string): OAuthCredentialSecret;
17
+ /** Merge and validate an OAuth token endpoint response without losing rotation state. */
18
+ export declare function mergeOAuthTokenResponse(previous: OAuthCredentialSecret | undefined, response: OAuthTokenResponse, opts?: {
19
+ now?: number;
20
+ }): OAuthCredentialSecret;
21
+ export declare function shouldRefreshOAuthCredential(secret: OAuthCredentialSecret, opts?: OAuthClockOptions): "no" | "refresh" | "login_required";
17
22
  export declare function isOAuthAccessTokenExpired(secret: Pick<OAuthCredentialSecret, "accessToken" | "expiresAt">, opts?: OAuthClockOptions): boolean;
18
23
  export declare function oauthCredentialStatus(secret: Pick<OAuthCredentialSecret, "accessToken" | "expiresAt">, opts?: OAuthClockOptions): Pick<OAuthCredentialPublicStatus, "state" | "expiresAt" | "expiresInMs">;
19
24
  export declare function buildOAuthRefreshRequest(credentialId: string, secret: OAuthCredentialSecret): OAuthRefreshRequest | undefined;
@@ -15,6 +15,17 @@ function optionalStringArray(value) {
15
15
  const out = value.filter((x) => typeof x === "string" && x.length > 0);
16
16
  return out.length > 0 ? out : undefined;
17
17
  }
18
+ function optionalFiniteNumber(value) {
19
+ if (value === undefined)
20
+ return undefined;
21
+ if (typeof value !== "number" || !Number.isFinite(value)) {
22
+ throw new Error("OAuth token response expires_in must be a finite non-negative number");
23
+ }
24
+ if (value < 0) {
25
+ throw new Error("OAuth token response expires_in must be a finite non-negative number");
26
+ }
27
+ return value;
28
+ }
18
29
  function parseExpiresAt(value) {
19
30
  if (!value)
20
31
  return undefined;
@@ -45,19 +56,73 @@ export function parseOAuthCredentialSecret(secret) {
45
56
  }
46
57
  const expiresAt = optionalString(raw.expiresAt);
47
58
  parseExpiresAt(expiresAt);
59
+ const tokenType = optionalString(raw.tokenType);
60
+ if (tokenType && tokenType.toLowerCase() !== "bearer") {
61
+ throw new Error("OAuth credential tokenType must be Bearer");
62
+ }
48
63
  return {
49
64
  version: raw.version === 1 ? 1 : undefined,
50
65
  accessToken,
51
66
  refreshToken: optionalString(raw.refreshToken),
52
67
  expiresAt,
53
- tokenType: optionalString(raw.tokenType),
68
+ tokenType,
54
69
  scope: optionalString(raw.scope),
55
70
  scopes: optionalStringArray(raw.scopes),
56
71
  tokenEndpoint: optionalString(raw.tokenEndpoint),
57
72
  clientId: optionalString(raw.clientId),
58
73
  clientSecret: optionalString(raw.clientSecret),
74
+ issuer: optionalString(raw.issuer),
75
+ resource: optionalString(raw.resource),
76
+ revocationEndpoint: optionalString(raw.revocationEndpoint),
77
+ clientRegistration: raw.clientRegistration &&
78
+ typeof raw.clientRegistration === "object" &&
79
+ !Array.isArray(raw.clientRegistration) &&
80
+ optionalString(raw.clientRegistration.clientId)
81
+ ? {
82
+ clientId: optionalString(raw.clientRegistration.clientId),
83
+ clientSecret: optionalString(raw.clientRegistration.clientSecret),
84
+ clientIdIssuedAt: typeof raw.clientRegistration.clientIdIssuedAt ===
85
+ "number"
86
+ ? raw.clientRegistration.clientIdIssuedAt
87
+ : undefined,
88
+ clientSecretExpiresAt: typeof raw.clientRegistration.clientSecretExpiresAt ===
89
+ "number"
90
+ ? raw.clientRegistration
91
+ .clientSecretExpiresAt
92
+ : undefined,
93
+ }
94
+ : undefined,
59
95
  };
60
96
  }
97
+ /** Merge and validate an OAuth token endpoint response without losing rotation state. */
98
+ export function mergeOAuthTokenResponse(previous, response, opts = {}) {
99
+ const accessToken = optionalString(response.access_token);
100
+ if (!accessToken)
101
+ throw new Error("OAuth token response must include access_token");
102
+ const tokenType = optionalString(response.token_type) ?? "Bearer";
103
+ if (tokenType.toLowerCase() !== "bearer") {
104
+ throw new Error(`OAuth token response token_type must be Bearer`);
105
+ }
106
+ const expiresIn = optionalFiniteNumber(response.expires_in);
107
+ const now = opts.now ?? Date.now();
108
+ const responseScope = optionalString(response.scope);
109
+ const refreshToken = optionalString(response.refresh_token) ?? previous?.refreshToken;
110
+ return {
111
+ ...previous,
112
+ version: 1,
113
+ accessToken,
114
+ refreshToken,
115
+ expiresAt: expiresIn === undefined ? undefined : new Date(now + expiresIn * 1000).toISOString(),
116
+ tokenType: "Bearer",
117
+ scope: responseScope ?? previous?.scope,
118
+ scopes: responseScope ? responseScope.split(/\s+/).filter(Boolean) : previous?.scopes,
119
+ };
120
+ }
121
+ export function shouldRefreshOAuthCredential(secret, opts = {}) {
122
+ if (!isOAuthAccessTokenExpired(secret, opts))
123
+ return "no";
124
+ return secret.refreshToken && secret.tokenEndpoint ? "refresh" : "login_required";
125
+ }
61
126
  export function isOAuthAccessTokenExpired(secret, opts = {}) {
62
127
  const expiresAtMs = parseExpiresAt(secret.expiresAt);
63
128
  if (expiresAtMs === undefined)
@@ -1,4 +1,4 @@
1
- import type { Credential } from "./types.js";
1
+ import { type Credential } from "./types.js";
2
2
  import { type EncryptionCipher } from "./cipher.js";
3
3
  export type CredentialScope = "user" | "project";
4
4
  export interface MaskedCredential extends Omit<Credential, "secret"> {
@@ -1,6 +1,7 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync } from "node:fs";
2
2
  import { join, dirname } from "node:path";
3
3
  import { homedir } from "node:os";
4
+ import { credentialAllowsEnvExposure, credentialSecretHint, } from "./types.js";
4
5
  import { getDefaultCredentialCipher } from "./cipher.js";
5
6
  import { logger } from "../logging/logger.js";
6
7
  import { summarizeOAuthCredentialSecret } from "./oauth.js";
@@ -93,11 +94,14 @@ export class CredentialStore {
93
94
  /** Upsert by id within a scope. */
94
95
  save(scope, cred) {
95
96
  const file = this.read(scope);
97
+ const safeCredential = credentialAllowsEnvExposure(cred.type)
98
+ ? cred
99
+ : { ...cred, exposeAsEnv: undefined };
96
100
  const idx = file.credentials.findIndex((c) => c.id === cred.id);
97
101
  if (idx >= 0)
98
- file.credentials[idx] = cred;
102
+ file.credentials[idx] = safeCredential;
99
103
  else
100
- file.credentials.push(cred);
104
+ file.credentials.push(safeCredential);
101
105
  this.write(scope, file);
102
106
  }
103
107
  /**
@@ -110,7 +114,12 @@ export class CredentialStore {
110
114
  const idx = file.credentials.findIndex((c) => c.id === id);
111
115
  if (idx < 0)
112
116
  return;
113
- file.credentials[idx] = { ...file.credentials[idx], ...fields };
117
+ const current = file.credentials[idx];
118
+ file.credentials[idx] = {
119
+ ...current,
120
+ ...fields,
121
+ ...(credentialAllowsEnvExposure(current.type) ? {} : { exposeAsEnv: undefined }),
122
+ };
114
123
  this.write(scope, file);
115
124
  }
116
125
  remove(scope, id) {
@@ -161,6 +170,8 @@ export class CredentialStore {
161
170
  : [this.read("user").credentials, this.read("project").credentials];
162
171
  for (const layer of layers) {
163
172
  for (const c of layer) {
173
+ if (!credentialAllowsEnvExposure(c.type))
174
+ continue;
164
175
  const name = c.exposeAsEnv?.trim();
165
176
  if (name && typeof c.secret === "string" && c.secret.length > 0) {
166
177
  out[name] = c.secret;
@@ -174,11 +185,9 @@ export class CredentialStore {
174
185
  const { secret, ...rest } = c;
175
186
  return {
176
187
  ...rest,
188
+ ...(credentialAllowsEnvExposure(c.type) ? {} : { exposeAsEnv: undefined }),
177
189
  hasSecret: typeof secret === "string" && secret.length > 0,
178
- // Reveal at most the last 4 chars — and ONLY when the secret is longer
179
- // than 4, so those chars aren't the whole secret. `"ab".slice(-4)` is
180
- // "ab", so a short secret would otherwise leak in full through the hint.
181
- secretHint: secret ? (secret.length > 4 ? `****${secret.slice(-4)}` : "****") : undefined,
190
+ secretHint: credentialSecretHint(c.type, secret),
182
191
  ...(c.type === "oauth" ? { oauthStatus: summarizeOAuthCredentialSecret(secret) } : {}),
183
192
  };
184
193
  });
@@ -4,6 +4,14 @@
4
4
  * (用户主动按域拓取存,支持同域多账号),见 credential-use-gate 设计稿。
5
5
  */
6
6
  export type CredentialType = "token" | "link" | "cookie" | "oauth";
7
+ /**
8
+ * Only established single-value credentials may be copied into a process
9
+ * environment. OAuth records and cookie jars are structured multi-secret
10
+ * values and must remain behind the credential resolver boundary.
11
+ */
12
+ export declare function credentialAllowsEnvExposure(type: CredentialType): boolean;
13
+ /** Build a renderer-safe hint without deriving bytes from structured JSON. */
14
+ export declare function credentialSecretHint(type: CredentialType, secret: string | undefined): string | undefined;
7
15
  export interface OAuthCredentialSecret {
8
16
  /** Schema version for the JSON stored in Credential.secret. */
9
17
  version?: 1;
@@ -25,6 +33,26 @@ export interface OAuthCredentialSecret {
25
33
  clientId?: string;
26
34
  /** Optional confidential client secret, if the integration requires it. */
27
35
  clientSecret?: string;
36
+ /** OAuth issuer discovered for the MCP protected resource. */
37
+ issuer?: string;
38
+ /** RFC 8707 resource indicator used for authorization/token requests. */
39
+ resource?: string;
40
+ /** Optional endpoint used to revoke the refresh/access token on logout. */
41
+ revocationEndpoint?: string;
42
+ /** Dynamic registration result, retained with the encrypted secret. */
43
+ clientRegistration?: {
44
+ clientId: string;
45
+ clientSecret?: string;
46
+ clientIdIssuedAt?: number;
47
+ clientSecretExpiresAt?: number;
48
+ };
49
+ }
50
+ export interface OAuthTokenResponse {
51
+ access_token: unknown;
52
+ refresh_token?: unknown;
53
+ expires_in?: unknown;
54
+ token_type?: unknown;
55
+ scope?: unknown;
28
56
  }
29
57
  export interface OAuthCredentialPublicStatus {
30
58
  state: "valid" | "expired" | "missing" | "invalid";
@@ -84,6 +112,11 @@ export interface Credential {
84
112
  switchMode?: "clear" | "merge";
85
113
  /** OAuth provider/integration id, e.g. "figma". */
86
114
  oauthProvider?: string;
115
+ /** MCP server identity associated with an advanced OAuth login. */
116
+ mcpServerName?: string;
117
+ mcpServerUrl?: string;
118
+ issuer?: string;
119
+ resource?: string;
87
120
  /** OAuth authorization endpoint, used by UI/login launchers. */
88
121
  authUrl?: string;
89
122
  /** OAuth token endpoint, duplicated from secret for non-secret UI display. */
@@ -92,8 +125,12 @@ export interface Credential {
92
125
  clientId?: string;
93
126
  /** OAuth scopes requested for this credential. */
94
127
  scopes?: string[];
95
- /** Last successful refresh time; refresh wiring is reserved for a later step. */
128
+ revocationEndpoint?: string;
129
+ /** Last successful refresh time. */
96
130
  lastRefreshAt?: string;
131
+ /** Normalized refresh diagnostics; never contains endpoint response bodies. */
132
+ lastRefreshFailedAt?: string;
133
+ lastRefreshErrorCode?: "invalid_grant" | "network" | "server_error" | "invalid_response";
97
134
  };
98
135
  }
99
136
  export interface CredentialStoreFile {
@@ -1 +1,16 @@
1
- export {};
1
+ /**
2
+ * Only established single-value credentials may be copied into a process
3
+ * environment. OAuth records and cookie jars are structured multi-secret
4
+ * values and must remain behind the credential resolver boundary.
5
+ */
6
+ export function credentialAllowsEnvExposure(type) {
7
+ return type === "token" || type === "link";
8
+ }
9
+ /** Build a renderer-safe hint without deriving bytes from structured JSON. */
10
+ export function credentialSecretHint(type, secret) {
11
+ if (!secret)
12
+ return undefined;
13
+ if (type === "oauth" || type === "cookie")
14
+ return "****";
15
+ return secret.length > 4 ? `****${secret.slice(-4)}` : "****";
16
+ }
@@ -4,10 +4,11 @@
4
4
  import type { LLMConfig, StreamCallback } from "../types.js";
5
5
  import { ToolRegistry } from "../tool-system/registry.js";
6
6
  import { type BuiltinToolFn } from "../tool-system/builtin/index.js";
7
+ import { type ApprovalRouter } from "../tool-system/permission.js";
7
8
  import { HookRegistry } from "../hooks/registry.js";
8
9
  import { type GoalConfig, type GoalExtension } from "./goal.js";
9
10
  import { type CompactStrategy } from "../context/manager.js";
10
- import { SessionManager } from "../session/session-manager.js";
11
+ import { SessionManager, type ForkSessionOptions, type ForkSessionResult } from "../session/session-manager.js";
11
12
  import type { AskUserFn } from "../tool-system/builtin/ask-user.js";
12
13
  import type { CapabilityOverride } from "../settings/schema.js";
13
14
  import { type FeatureFlagName } from "../settings/feature-flags.js";
@@ -34,6 +35,38 @@ export interface EnqueueSteerResult {
34
35
  accepted: boolean;
35
36
  id: string;
36
37
  }
38
+ interface EngineRunOptions {
39
+ cwd?: string;
40
+ onStream?: StreamCallback;
41
+ signal?: AbortSignal;
42
+ sessionId?: string;
43
+ /** Immutable permission context for this run only. Omitted uses Engine config. */
44
+ permissionMode?: NonNullable<EngineConfig["permissionMode"]>;
45
+ /** Immutable plan context for this run only. Omitted follows permissionMode. */
46
+ planMode?: boolean;
47
+ /** Protocol connection's approval owner router for this run. */
48
+ approvalRouter?: ApprovalRouter;
49
+ /**
50
+ * Goal mode for this run: the engine registers a GoalStopHook so the
51
+ * turn loop runs until the session model judges this goal met. Falls
52
+ * back to config.goal. Orthogonal to permissionMode. Accepts a raw
53
+ * string or a full GoalConfig; normalized once at the run boundary.
54
+ */
55
+ goal?: string | GoalConfig;
56
+ /**
57
+ * Marks this turn's task as a SYNTHETIC system-reminder injection (e.g. a
58
+ * background-job completion notification the server re-injects to wake an
59
+ * idle session) rather than the user's own input. The task is still sent
60
+ * to the model as a `role:"user"` message, but it is persisted with an
61
+ * `injected` flag so the disk reader skips rendering it as a user bubble
62
+ * on replay (matching the live UI, which shows only the assistant reply).
63
+ */
64
+ injected?: boolean;
65
+ /** Stable id for this user-intent, used to make duplicate submits idempotent. */
66
+ clientMessageId?: string;
67
+ /** Structured input attachments. Legacy `<codeshell-image>` blocks remain supported. */
68
+ attachments?: InputAttachmentMeta[];
69
+ }
37
70
  export { diskDefaultsFrom, type DiskDefaultPatch } from "../settings/disk-defaults.js";
38
71
  /**
39
72
  * Resolve the LLM config for a spawned child Engine.
@@ -122,6 +155,8 @@ export declare class Engine {
122
155
  /** Shared resources supplied at construction (adapter pattern — null when self-constructed). */
123
156
  readonly runtime: EngineRuntime | null;
124
157
  private readonly sandboxCache;
158
+ private readonly interactiveBackends;
159
+ private activeApprovalRouter;
125
160
  /** Active permission mode for this Engine instance. */
126
161
  permissionMode: NonNullable<EngineConfig["permissionMode"]>;
127
162
  /** True when permissionMode === "plan". */
@@ -188,6 +223,19 @@ export declare class Engine {
188
223
  * Null when idle; set at run start, cleared in run's finally.
189
224
  */
190
225
  private activeRunSession;
226
+ /**
227
+ * Same-instance run guard. Engine owns single-valued live controls and one
228
+ * HookRegistry, so a second run must not enter until the first has completed
229
+ * all state persistence and end hooks. This prevents handle contamination and
230
+ * whole-state saveState overlap only within this Engine instance. It does not
231
+ * coordinate different Engine instances sharing a sessionId, Workers, or
232
+ * processes; session-level locking/CAS for those cases is a separate finding.
233
+ */
234
+ private runInProgress;
235
+ /** Permission update requested while runInProgress. Applied in run() finally. */
236
+ private pendingPermissionMode;
237
+ /** Plan update paired with pendingPermissionMode for one atomic boundary apply. */
238
+ private pendingPlanMode;
191
239
  /** Public accessor so UI/clients can read the resolved per-model window. */
192
240
  get maxContextTokens(): number;
193
241
  private resolveMaxContextTokens;
@@ -306,35 +354,14 @@ export declare class Engine {
306
354
  * against a blank session. A stat probe, not a load.
307
355
  */
308
356
  sessionExistsOnDisk(sessionId: string): boolean;
357
+ forkSession(sourceSessionId: string, options?: ForkSessionOptions): ForkSessionResult;
309
358
  /**
310
- * Run a task from start to finish.
311
- */
312
- run(task: string, options?: {
313
- cwd?: string;
314
- onStream?: StreamCallback;
315
- signal?: AbortSignal;
316
- sessionId?: string;
317
- /**
318
- * Goal mode for this run: the engine registers a GoalStopHook so the
319
- * turn loop runs until the session model judges this goal met. Falls
320
- * back to config.goal. Orthogonal to permissionMode. Accepts a raw
321
- * string or a full GoalConfig; normalized once at the run boundary.
322
- */
323
- goal?: string | GoalConfig;
324
- /**
325
- * Marks this turn's task as a SYNTHETIC system-reminder injection (e.g. a
326
- * background-job completion notification the server re-injects to wake an
327
- * idle session) rather than the user's own input. The task is still sent
328
- * to the model as a `role:"user"` message, but it is persisted with an
329
- * `injected` flag so the disk reader skips rendering it as a user bubble
330
- * on replay (matching the live UI, which shows only the assistant reply).
331
- */
332
- injected?: boolean;
333
- /** Stable id for this user-intent, used to make duplicate submits idempotent. */
334
- clientMessageId?: string;
335
- /** Structured input attachments. Legacy `<codeshell-image>` blocks remain supported. */
336
- attachments?: InputAttachmentMeta[];
337
- }): Promise<EngineResult>;
359
+ * Run a task from start to finish. Rejects immediately when this Engine
360
+ * instance already has a run in progress; hosts that want queueing own that
361
+ * policy (for example ChatSession's FIFO queue).
362
+ */
363
+ run(task: string, options?: EngineRunOptions): Promise<EngineResult>;
364
+ private runExclusive;
338
365
  /**
339
366
  * Run the end-of-session memory pipeline as a fire-and-forget background
340
367
  * task. Extracts durable memories from the transcript, saves a session
@@ -499,12 +526,14 @@ export declare class Engine {
499
526
  readSetting(key: string): unknown;
500
527
  private buildPermissionConfig;
501
528
  /**
502
- * Switch permission mode at runtime. Takes effect immediately for any
503
- * in-flight ToolExecutor (which holds a reference to the same classifier),
504
- * and the new mode is used for any subsequent run() calls.
529
+ * Switch permission mode at runtime. Idle updates apply immediately; busy
530
+ * updates are committed atomically when the current run settles, so its
531
+ * classifier and ToolContext retain the immutable start-of-run snapshot.
505
532
  * Session-only — does not persist to settings.
506
533
  */
507
534
  setPermissionMode(mode: NonNullable<EngineConfig["permissionMode"]>): void;
535
+ private applyPermissionState;
536
+ private applyPendingPermissionState;
508
537
  getPermissionMode(): NonNullable<EngineConfig["permissionMode"]>;
509
538
  /**
510
539
  * Extend the in-flight run's turn ceiling and/or goal budgets (TODO 3.1 —