@opengeni/runtime 0.13.4 → 0.13.10

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/src/index.ts CHANGED
@@ -23,6 +23,7 @@ import {
23
23
  normalizeResourceMountPath,
24
24
  prefixedMcpToolName as sharedPrefixedMcpToolName,
25
25
  resourceMountPath,
26
+ redactSensitiveText,
26
27
  sessionEventMediaPreview,
27
28
  sessionEventMediaPreviewFromDataUrl,
28
29
  signDelegatedAccessToken,
@@ -186,6 +187,14 @@ import {
186
187
  } from "./sandbox/turn-tool-cancellation";
187
188
  import { computerUse, type ComputerToolMode } from "./sandbox-computer";
188
189
  import type { RuntimeMetricsHooks } from "./metrics";
190
+
191
+ // The Agents SDK's debug namespaces can otherwise serialize complete model
192
+ // inputs/outputs and tool arguments/results. These getters read process.env on
193
+ // every log call, so keep the process-global guard permanently enabled rather
194
+ // than toggling it around concurrent turns.
195
+ process.env.OPENAI_AGENTS_DONT_LOG_MODEL_DATA = "1";
196
+ process.env.OPENAI_AGENTS_DONT_LOG_TOOL_DATA = "1";
197
+
189
198
  export {
190
199
  getSkillLibraryEntry,
191
200
  isSkillLibraryEntryId,
@@ -1621,6 +1630,35 @@ export function appendToolspaceInstructions(composed: string, toolspaceAvailable
1621
1630
  return toolspaceAvailable ? `${composed} ${TOOLSPACE_PROGRAMMATIC_DIRECTIVE}` : composed;
1622
1631
  }
1623
1632
 
1633
+ const GIT_BINDING_DISCOVERY_DIRECTIVE =
1634
+ "This managed sandbox has multiple credential bindings for at least one Git provider. Native Git selects credentials from the repository remote. For gh, glab, or az, run inside the intended attached repository so its origin selects the binding; when running elsewhere, inspect $HOME/.opengeni/git-bindings.json and set OPENGENI_GIT_BINDING to the listed credentialBindingId. The inventory contains identifiers and repository routes, never credential values. A binding marked git_http_broker is Git-only, so use the configured provider MCP tools for provider API operations on it.";
1635
+
1636
+ /**
1637
+ * Make multi-account provider-CLI selection discoverable to the model.
1638
+ *
1639
+ * Binding ids are opaque routing identifiers, not credentials. The detailed,
1640
+ * secret-free repository mapping is materialized in the sandbox by the Git
1641
+ * setup hook so prompts stay compact.
1642
+ */
1643
+ export function appendGitCredentialBindingInstructions(
1644
+ composed: string,
1645
+ bindings: GitCredentialBindingSeed[] | undefined,
1646
+ activeSandboxBackend?: Settings["sandboxBackend"],
1647
+ ): string {
1648
+ if (activeSandboxBackend === "selfhosted" || !bindings?.length) {
1649
+ return composed;
1650
+ }
1651
+ const bindingsByProvider = new Map<GitCredentialProvider, Set<string>>();
1652
+ for (const binding of bindings) {
1653
+ const ids = bindingsByProvider.get(binding.provider) ?? new Set<string>();
1654
+ ids.add(binding.credentialBindingId);
1655
+ bindingsByProvider.set(binding.provider, ids);
1656
+ }
1657
+ return [...bindingsByProvider.values()].some((ids) => ids.size > 1)
1658
+ ? `${composed} ${GIT_BINDING_DISCOVERY_DIRECTIVE}`
1659
+ : composed;
1660
+ }
1661
+
1624
1662
  /**
1625
1663
  * Appends the one-shot genesis title directive (genesis turn only), joined by
1626
1664
  * " " and always LAST so a white-label persona template or a per-session
@@ -1687,7 +1725,7 @@ export function mcpToolErrorOutput(error: unknown): {
1687
1725
  isError: true;
1688
1726
  content: [{ type: "text"; text: string }];
1689
1727
  } {
1690
- const details = error instanceof Error ? error.message : String(error);
1728
+ const details = redactSensitiveText(error instanceof Error ? error.message : String(error));
1691
1729
  return {
1692
1730
  isError: true,
1693
1731
  content: [
@@ -1776,12 +1814,14 @@ export function buildOpenGeniAgent(
1776
1814
  // when a toolspace token was minted for this turn (feature enabled + a
1777
1815
  // per-turn seed — the mint gate, which includes selfhosted turns since
1778
1816
  // they now receive the token too) — appendToolspaceInstructions,
1779
- // 3. + workspace memory working set, ONLY when the workspace setting is on
1817
+ // 3. + managed-sandbox Git binding discovery, ONLY when one provider has
1818
+ // multiple credential bindings,
1819
+ // 4. + workspace memory working set, ONLY when the workspace setting is on
1780
1820
  // and the worker resolved a nonblank block — appendWorkspaceMemory,
1781
- // 4. + the per-session persona instructions (session-specific, so it
1821
+ // 5. + the per-session persona instructions (session-specific, so it
1782
1822
  // refines both the workspace persona and the substrate note),
1783
- // 5. + host context for this exact turn, when supplied,
1784
- // 6. + durable session-setting state (title present + child notification
1823
+ // 6. + host context for this exact turn, when supplied,
1824
+ // 7. + durable session-setting state (title present + child notification
1785
1825
  // mode), when supplied by the worker,
1786
1826
  // The genesis title directive is deliberately NOT part of this persistent
1787
1827
  // string. runAgentStream injects it into the first model call only.
@@ -1789,13 +1829,17 @@ export function buildOpenGeniAgent(
1789
1829
  appendTurnInstructions(
1790
1830
  appendSessionInstructions(
1791
1831
  appendWorkspaceMemory(
1792
- appendToolspaceInstructions(
1793
- composeAgentInstructions(
1794
- options.instructionsTemplate ?? settings.agentInstructionsTemplate,
1795
- options.workspaceEnvironment,
1796
- options.rig,
1832
+ appendGitCredentialBindingInstructions(
1833
+ appendToolspaceInstructions(
1834
+ composeAgentInstructions(
1835
+ options.instructionsTemplate ?? settings.agentInstructionsTemplate,
1836
+ options.workspaceEnvironment,
1837
+ options.rig,
1838
+ ),
1839
+ settings.toolspaceEnabled && Boolean(options.toolspaceTokenSeed),
1797
1840
  ),
1798
- settings.toolspaceEnabled && Boolean(options.toolspaceTokenSeed),
1841
+ options.gitCredentialBindings,
1842
+ options.activeSandboxBackend,
1799
1843
  ),
1800
1844
  options.workspaceMemory,
1801
1845
  ),
@@ -1938,13 +1982,13 @@ export function buildOpenGeniAgent(
1938
1982
  }
1939
1983
 
1940
1984
  /**
1941
- * Enable Codex-CLI-style progressive connector disclosure on a codex turn when the
1985
+ * Enable Codex-CLI-style progressive MCP disclosure on a Codex turn when the
1942
1986
  * flag is on. Gated on `structuredToolTransport === false` — the same signal that
1943
- * identifies a codex-subscription turn (the ChatGPT backend that rejects hosted
1944
- * tools) — so no non-codex turn is ever touched. On qualifying turns it wraps
1987
+ * identifies a Codex-subscription turn (the ChatGPT backend that rejects hosted
1988
+ * tools) — so no non-Codex turn is ever touched. On qualifying turns it wraps
1945
1989
  * `getAllTools` (clone-survivingly — see {@link installCodexToolSearch}) to defer
1946
- * codex_apps schemas + add the client tool_search tool, whose description renders
1947
- * the live connector namespaces threaded from prepareAgentTools.
1990
+ * selected non-mandatory MCP schemas and add the client tool_search tool. The
1991
+ * description combines live connector namespaces with selected server identities.
1948
1992
  */
1949
1993
  function maybeInstallCodexToolSearch(
1950
1994
  agent: Agent<any, any>,
@@ -1952,9 +1996,23 @@ function maybeInstallCodexToolSearch(
1952
1996
  options: BuildAgentOptions,
1953
1997
  ): void {
1954
1998
  if (settings.codexToolSearchEnabled && options.structuredToolTransport === false) {
1999
+ const mcpServers = options.mcpServers ?? [];
2000
+ // `defer_loading:true` removes these MCP schemas from provider context until
2001
+ // tool_search discloses a bounded match. Keep the compaction estimator on
2002
+ // that same wire truth; otherwise a large deferred catalog can falsely trip
2003
+ // compaction before the first real model request. OpenGeni remains eager.
2004
+ for (const server of mcpServers) {
2005
+ if (server.name === "opengeni") continue;
2006
+ (
2007
+ server as MCPServer & {
2008
+ deferModelToolSchemaAccounting?: () => void;
2009
+ }
2010
+ ).deferModelToolSchemaAccounting?.();
2011
+ }
1955
2012
  installCodexToolSearch(
1956
2013
  agent as unknown as Parameters<typeof installCodexToolSearch>[0],
1957
2014
  options.codexConnectorNamespaces ?? new Set<string>(),
2015
+ new Set(mcpServers.map((server) => server.name)),
1958
2016
  );
1959
2017
  }
1960
2018
  }
@@ -2468,6 +2526,10 @@ export async function prepareAgentTools(
2468
2526
  url,
2469
2527
  name: config.name ?? config.id,
2470
2528
  cacheToolsList: config.cacheToolsList,
2529
+ // The upstream transport logger receives raw thrown errors, whose
2530
+ // messages may contain response bodies, URLs, headers, or echoed
2531
+ // credentials. Keep its diagnostic surface structural only.
2532
+ logger: safeMcpTransportLogger(config.id),
2471
2533
  // codex_apps returns connector tools with empty `outputSchema: {}` that the
2472
2534
  // MCP SDK's strict Tool schema rejects (fails the turn during tools/list);
2473
2535
  // sanitize the response on the wire before validation. The namespace Set
@@ -2534,7 +2596,7 @@ export async function prepareAgentTools(
2534
2596
  const error = connectedBestEffort.errors.get(failed);
2535
2597
  console.warn(
2536
2598
  `[mcp] optional server "${failed.name}" failed to connect/list tools; skipping it for this turn`,
2537
- error instanceof Error ? error.message : error,
2599
+ safeMcpErrorFields(error),
2538
2600
  );
2539
2601
  }
2540
2602
  }
@@ -2948,6 +3010,46 @@ function safeMcpErrorFields(error: unknown): {
2948
3010
  return { errorClass, status };
2949
3011
  }
2950
3012
 
3013
+ function safeMcpTransportError(error: unknown): Error & { status?: number; code?: number } {
3014
+ const fields = safeMcpErrorFields(error);
3015
+ const safeError = new Error(
3016
+ `MCP transport operation failed (${mcpErrorReason(fields)})`,
3017
+ ) as Error & {
3018
+ status?: number;
3019
+ code?: number;
3020
+ };
3021
+ safeError.name = "McpTransportError";
3022
+ if (fields.status !== undefined) {
3023
+ safeError.status = fields.status;
3024
+ safeError.code = fields.status;
3025
+ }
3026
+ return safeError;
3027
+ }
3028
+
3029
+ function safeMcpTransportLogger(serverId: string) {
3030
+ const logFailure = (_message: string, ...args: unknown[]) => {
3031
+ let error: unknown;
3032
+ for (let index = args.length - 1; index >= 0; index -= 1) {
3033
+ if (args[index] instanceof Error) {
3034
+ error = args[index];
3035
+ break;
3036
+ }
3037
+ }
3038
+ console.warn("[mcp] transport operation failed", {
3039
+ serverId,
3040
+ ...safeMcpErrorFields(error),
3041
+ });
3042
+ };
3043
+ return {
3044
+ namespace: "opengeni:mcp-transport",
3045
+ debug: () => undefined,
3046
+ error: logFailure,
3047
+ warn: logFailure,
3048
+ dontLogModelData: true,
3049
+ dontLogToolData: true,
3050
+ };
3051
+ }
3052
+
2951
3053
  // Compose the safe model/log reason string ("StreamableHTTPError 401", or just
2952
3054
  // the class when no numeric status is available). Never carries the raw body.
2953
3055
  function mcpErrorReason(fields: { errorClass: string; status?: number }): string {
@@ -3206,6 +3308,7 @@ class PrefixedMcpServer implements MCPServer {
3206
3308
  private readonly bestEffort: boolean;
3207
3309
  private loggedListToolsFailure = false;
3208
3310
  private listedToolSchemaTokens = 0;
3311
+ private modelToolSchemaAccountingDeferred = false;
3209
3312
 
3210
3313
  constructor(
3211
3314
  private readonly inner: MCPServer,
@@ -3223,7 +3326,11 @@ class PrefixedMcpServer implements MCPServer {
3223
3326
  }
3224
3327
 
3225
3328
  connect(): Promise<void> {
3226
- return this.inner.connect();
3329
+ return this.inner.connect().catch((error: unknown) => {
3330
+ // connectMcpServers has its own global logger and logs the thrown Error.
3331
+ // Never let a raw transport response body cross that logging boundary.
3332
+ throw safeMcpTransportError(error);
3333
+ });
3227
3334
  }
3228
3335
 
3229
3336
  close(): Promise<void> {
@@ -3283,7 +3390,16 @@ class PrefixedMcpServer implements MCPServer {
3283
3390
 
3284
3391
  /** Latest exact tools/list projection used to build the model request. */
3285
3392
  modelToolSchemaTokens(): number {
3286
- return this.listedToolSchemaTokens;
3393
+ return this.modelToolSchemaAccountingDeferred ? 0 : this.listedToolSchemaTokens;
3394
+ }
3395
+
3396
+ /**
3397
+ * Keep model-input accounting aligned with Codex `defer_loading`: the full
3398
+ * schema is not provider context until a tool_search_output discloses it.
3399
+ * Instances are turn-local, so this cannot leak into a non-Codex turn.
3400
+ */
3401
+ deferModelToolSchemaAccounting(): void {
3402
+ this.modelToolSchemaAccountingDeferred = true;
3287
3403
  }
3288
3404
 
3289
3405
  async callTool(
@@ -5346,6 +5462,7 @@ function sandboxFileDownloadCommand(download: SandboxFileDownload, targetPath: s
5346
5462
  const targetDir = posixPath.dirname(targetPath);
5347
5463
  const tmpPath = `${targetPath}.opengeni-download-$$`;
5348
5464
  return [
5465
+ "set +x",
5349
5466
  "set -eu",
5350
5467
  `mkdir -p -- ${shellQuote(targetDir)}`,
5351
5468
  `if [ ! -f ${shellQuote(targetPath)} ]; then`,
@@ -5768,6 +5885,7 @@ type RuntimeGitBindingDescriptor = {
5768
5885
  host: string;
5769
5886
  path: string;
5770
5887
  uri: string;
5888
+ mountPath: string;
5771
5889
  };
5772
5890
 
5773
5891
  type RuntimeGitHttpBrokerRouteDescriptor = {
@@ -5820,10 +5938,61 @@ function runtimeGitBindingDescriptors(
5820
5938
  host: url.host.toLowerCase(),
5821
5939
  path,
5822
5940
  uri: resource.uri,
5941
+ mountPath: `/workspace/${resourceMountPath(resource)}`,
5823
5942
  };
5824
5943
  });
5825
5944
  }
5826
5945
 
5946
+ function gitCredentialBindingInventoryCommandLines(
5947
+ resources: Extract<ResourceRef, { kind: "repository" }>[],
5948
+ bindings: GitCredentialBindingSeed[],
5949
+ ): string[] {
5950
+ type GitBindingInventoryTransport = "direct_token" | "git_http_broker";
5951
+ type GitBindingInventoryEntry = {
5952
+ credentialBindingId: string;
5953
+ provider: GitCredentialProvider;
5954
+ transport: GitBindingInventoryTransport;
5955
+ repositories: Array<{ uri: string; mountPath: string }>;
5956
+ };
5957
+ const bindingTransports = new Map<string, GitBindingInventoryTransport>(
5958
+ bindings.map((binding) => [
5959
+ gitCredentialBindingKey(binding.provider, binding.credentialBindingId),
5960
+ binding.transport?.kind === "http_broker" ? "git_http_broker" : "direct_token",
5961
+ ]),
5962
+ );
5963
+ const entries = new Map<string, GitBindingInventoryEntry>();
5964
+ for (const descriptor of runtimeGitBindingDescriptors(resources)) {
5965
+ const key = gitCredentialBindingKey(descriptor.provider, descriptor.credentialBindingId);
5966
+ const transport = bindingTransports.get(key);
5967
+ if (!transport) continue;
5968
+ const entry: GitBindingInventoryEntry = entries.get(key) ?? {
5969
+ credentialBindingId: descriptor.credentialBindingId,
5970
+ provider: descriptor.provider,
5971
+ transport,
5972
+ repositories: [],
5973
+ };
5974
+ entry.repositories.push({ uri: descriptor.uri, mountPath: descriptor.mountPath });
5975
+ entries.set(key, entry);
5976
+ }
5977
+ const inventory = `${JSON.stringify(
5978
+ {
5979
+ version: 1,
5980
+ bindings: [...entries.values()],
5981
+ },
5982
+ null,
5983
+ 2,
5984
+ )}\n`;
5985
+ return [
5986
+ 'git_binding_inventory="${OPENGENI_GIT_BINDINGS_FILE:-$HOME/.opengeni/git-bindings.json}"',
5987
+ 'mkdir -p "$(dirname "$git_binding_inventory")"',
5988
+ 'inventory_umask="$(umask)"',
5989
+ "umask 077",
5990
+ `printf '%s' ${shellQuote(inventory)} > "$git_binding_inventory.tmp.$$"`,
5991
+ 'mv -f "$git_binding_inventory.tmp.$$" "$git_binding_inventory"',
5992
+ 'umask "$inventory_umask"',
5993
+ ];
5994
+ }
5995
+
5827
5996
  function gitCredentialBindingKey(
5828
5997
  provider: GitCredentialProvider,
5829
5998
  credentialBindingId: string,
@@ -6175,6 +6344,7 @@ function gitCredentialHelperCommandLines(
6175
6344
  .map(([provider]) => provider);
6176
6345
  return [
6177
6346
  ...gitCredentialTokenWriterCommandLines(bindings),
6347
+ ...gitCredentialBindingInventoryCommandLines(resources, bindings),
6178
6348
  // Provision git/provider-CLI helpers at SETUP (runtime) before any clone
6179
6349
  // runs. Renewal updates only token files and deliberately leaves these
6180
6350
  // repository-specific host mappings intact.
@@ -6337,7 +6507,7 @@ function gitCredentialHelperCommandLines(
6337
6507
  ' *" $provider "*) printf \'%s\\n\' "$tool provider API authentication is host-brokered for this session; use the configured provider MCP tools" >&2; exit 2 ;;',
6338
6508
  " esac",
6339
6509
  ' case " $multi_binding_providers " in',
6340
- ' *" $provider "*) printf \'%s\\n\' "Unable to select one of multiple $provider credentials; run inside an attached repository or set OPENGENI_GIT_BINDING" >&2; exit 2 ;;',
6510
+ ' *" $provider "*) printf \'%s\\n\' "Unable to select one of multiple $provider credentials; run inside an attached repository, or inspect ${OPENGENI_GIT_BINDINGS_FILE:-$HOME/.opengeni/git-bindings.json} and set OPENGENI_GIT_BINDING" >&2; exit 2 ;;',
6341
6511
  " esac",
6342
6512
  ' case "$provider" in',
6343
6513
  ' github) token_file="${OPENGENI_GIT_TOKEN_FILE:-$HOME/.opengeni/git-token}" ;;',
@@ -6391,6 +6561,7 @@ export function gitProviderTokenRefreshCommand(seeds: GitTokenSeeds): string {
6391
6561
  return "";
6392
6562
  }
6393
6563
  return [
6564
+ "set +x",
6394
6565
  seedPrefix,
6395
6566
  "set -eu",
6396
6567
  'export HOME="${HOME:-/workspace}"',
@@ -6404,6 +6575,7 @@ export function gitCredentialBindingTokenRefreshCommand(
6404
6575
  const seedPrefix = gitCredentialBindingSeedExportPrefix(bindings);
6405
6576
  if (!seedPrefix) return "";
6406
6577
  return [
6578
+ "set +x",
6407
6579
  seedPrefix,
6408
6580
  "set -eu",
6409
6581
  'export HOME="${HOME:-/workspace}"',
@@ -6459,6 +6631,7 @@ export function repositoryCloneCommand(
6459
6631
  ): string {
6460
6632
  assertUniqueResourceMountPaths(resources);
6461
6633
  const commands = [
6634
+ "set +x",
6462
6635
  "set -eu",
6463
6636
  'export HOME="${HOME:-/workspace}"',
6464
6637
  'export GIT_TERMINAL_PROMPT="${GIT_TERMINAL_PROMPT:-0}"',
@@ -6568,6 +6741,7 @@ export function toolspaceTokenSeedCommand(
6568
6741
  options: { tokenFile?: string; legacyTokenFile?: string } = {},
6569
6742
  ): string {
6570
6743
  return [
6744
+ "set +x",
6571
6745
  "set -eu",
6572
6746
  'export HOME="${HOME:-/workspace}"',
6573
6747
  'if [ -n "${OPENGENI_TOOLSPACE_TOKEN_SEED:-}" ]; then',
@@ -6597,7 +6771,7 @@ export async function runToolspaceTokenSeedHook(
6597
6771
  if (!context.toolspaceTokenSeed) {
6598
6772
  return;
6599
6773
  }
6600
- const command = `export OPENGENI_TOOLSPACE_TOKEN_SEED=${shellQuote(context.toolspaceTokenSeed)}\n${toolspaceTokenSeedCommand(
6774
+ const command = `set +x\nexport OPENGENI_TOOLSPACE_TOKEN_SEED=${shellQuote(context.toolspaceTokenSeed)}\n${toolspaceTokenSeedCommand(
6601
6775
  {
6602
6776
  ...(context.toolspaceTokenFile ? { tokenFile: context.toolspaceTokenFile } : {}),
6603
6777
  ...(context.toolspaceTokenFile && context.environment.OPENGENI_TOOLSPACE_TOKEN_FILE
@@ -6629,7 +6803,7 @@ export async function refreshToolspaceTokenFile(
6629
6803
  legacyTokenFile?: string;
6630
6804
  } = {},
6631
6805
  ): Promise<void> {
6632
- const command = `export OPENGENI_TOOLSPACE_TOKEN_SEED=${shellQuote(token)}\n${toolspaceTokenSeedCommand(
6806
+ const command = `set +x\nexport OPENGENI_TOOLSPACE_TOKEN_SEED=${shellQuote(token)}\n${toolspaceTokenSeedCommand(
6633
6807
  {
6634
6808
  ...(options.tokenFile ? { tokenFile: options.tokenFile } : {}),
6635
6809
  ...(options.legacyTokenFile ? { legacyTokenFile: options.legacyTokenFile } : {}),
@@ -6842,7 +7016,7 @@ export async function runRepositoryCloneHook(
6842
7016
  .filter(Boolean)
6843
7017
  .join("\n");
6844
7018
  const cloneCommand = repositoryCloneCommand(resources, gitCredentialBindings);
6845
- const command = seedPrefix ? `${seedPrefix}\n${cloneCommand}` : cloneCommand;
7019
+ const command = seedPrefix ? `set +x\n${seedPrefix}\n${cloneCommand}` : cloneCommand;
6846
7020
  const result = await runSandboxLifecycleCommand(
6847
7021
  session,
6848
7022
  {
@@ -6873,6 +7047,7 @@ export async function runRepositoryCloneHook(
6873
7047
 
6874
7048
  export function azureCliLoginCommand(): string {
6875
7049
  return [
7050
+ "set +x",
6876
7051
  'export HOME="${HOME:-/workspace}"',
6877
7052
  'mkdir -p "$HOME/.azure"',
6878
7053
  'CLIENT_ID="${AZURE_CLIENT_ID:-${ARM_CLIENT_ID:-}}"',
@@ -379,6 +379,7 @@ export {
379
379
  type DefaultBackendLossResult,
380
380
  type RoutableBackendSession,
381
381
  type ResolvedActiveBackend,
382
+ type RoutingMutationSettlementResult,
382
383
  type RoutingRetainedProcess,
383
384
  type RoutingRetainedProcessAdoption,
384
385
  type RoutingRetainedProcessTerminalProof,
@@ -115,6 +115,15 @@ export type RoutingRetainedProcess = {
115
115
  providerSessionId: number;
116
116
  };
117
117
 
118
+ /** A yielded process was durably promoted, but the mutable authority checked
119
+ * after that commit was stale. The provider output must still be rejected;
120
+ * this result carries only the safe process identity needed to drain the exact
121
+ * original backend without replaying the mutation. */
122
+ export type RoutingMutationSettlementResult = {
123
+ status: "retained_process_durable_output_rejected";
124
+ retainedProcess: RoutingRetainedProcess;
125
+ };
126
+
118
127
  /** Exact copied route identity required to reconstruct an already-durable
119
128
  * retained process after the request/runtime object that opened it is gone.
120
129
  * The live session is deliberately not caller-supplied: adoption may bind only
@@ -184,7 +193,7 @@ export interface RoutingSandboxSessionDeps {
184
193
  /** Stable candidate generated before durable promotion is attempted. It is
185
194
  * supplied only when the provider returned a positive yielded-session id. */
186
195
  retainedProcess?: RoutingRetainedProcess;
187
- }) => Promise<void>;
196
+ }) => Promise<void | RoutingMutationSettlementResult>;
188
197
  /** Admit one model/user-visible stdin mutation under the already-durable
189
198
  * retained process authority. Control polling and helper execs never call it. */
190
199
  beforeProcessMutation?: (input: {
@@ -265,13 +274,15 @@ export class RoutingBackendRecoveryRequiredError extends Error {
265
274
  export class RoutingMutationOutcomeUnknownError extends Error {
266
275
  readonly name = "RoutingMutationOutcomeUnknownError";
267
276
  readonly retryable = false;
277
+ readonly retainedProcess: RoutingRetainedProcess | null;
268
278
 
269
279
  constructor(
270
280
  public readonly op: string,
271
281
  message: string,
272
- options?: { cause?: unknown },
282
+ options?: { cause?: unknown; retainedProcess?: RoutingRetainedProcess },
273
283
  ) {
274
284
  super(message, options);
285
+ this.retainedProcess = options?.retainedProcess ?? null;
275
286
  }
276
287
  }
277
288
 
@@ -665,7 +676,8 @@ export class RoutingSandboxSession implements RoutableBackendSession {
665
676
  );
666
677
  }
667
678
  try {
668
- await this.deps.afterMutation(pending);
679
+ const result = await this.deps.afterMutation(pending);
680
+ if (result) this.confirmDurableRejectedPromotion(record, result);
669
681
  record.pendingParentPromotion = null;
670
682
  record.durable = true;
671
683
  } catch (error) {
@@ -677,6 +689,24 @@ export class RoutingSandboxSession implements RoutableBackendSession {
677
689
  }
678
690
  }
679
691
 
692
+ private confirmDurableRejectedPromotion(
693
+ record: RetainedProcessRecord,
694
+ result: RoutingMutationSettlementResult,
695
+ ): void {
696
+ if (
697
+ result.status !== "retained_process_durable_output_rejected" ||
698
+ result.retainedProcess.id !== record.process.id ||
699
+ result.retainedProcess.providerSessionId !== record.process.providerSessionId
700
+ ) {
701
+ throw new RoutingMutationOutcomeUnknownError(
702
+ "retainProcess",
703
+ `Durable promotion result did not match retained provider session ${record.process.providerSessionId}`,
704
+ );
705
+ }
706
+ record.pendingParentPromotion = null;
707
+ record.durable = true;
708
+ }
709
+
680
710
  private async settleRetainedProcess(
681
711
  record: RetainedProcessRecord,
682
712
  proof: RoutingRetainedProcessTerminalProof,
@@ -936,9 +966,24 @@ export class RoutingSandboxSession implements RoutableBackendSession {
936
966
  ...(retainedProcess ? { retainedProcess } : {}),
937
967
  };
938
968
  try {
939
- await this.deps.afterMutation(settlement);
969
+ const settlementResult = await this.deps.afterMutation(settlement);
970
+ if (retainedRecord && settlementResult) {
971
+ this.confirmDurableRejectedPromotion(retainedRecord, settlementResult);
972
+ this.invalidate(backend);
973
+ throw new RoutingMutationOutcomeUnknownError(
974
+ op,
975
+ `Mutating sandbox operation "${op}" yielded provider session ${retainedRecord.process.providerSessionId}; durable promotion succeeded but stale authority rejected its output, and the operation was not replayed`,
976
+ { retainedProcess: retainedRecord.process },
977
+ );
978
+ }
940
979
  if (retainedRecord) retainedRecord.durable = true;
941
980
  } catch (error) {
981
+ if (
982
+ error instanceof RoutingMutationOutcomeUnknownError &&
983
+ error.retainedProcess !== null
984
+ ) {
985
+ throw error;
986
+ }
942
987
  if (retainedRecord) retainedRecord.pendingParentPromotion = settlement;
943
988
  this.invalidate(backend);
944
989
  throw new RoutingMutationOutcomeUnknownError(
@@ -496,9 +496,9 @@ async function runCredentialCommand(
496
496
  cmd: string,
497
497
  commandRunner?: RunCredentialCommandRunner,
498
498
  ): Promise<void> {
499
- const marked = `set -eu\n${cmd}\nprintf '\\n${COMMAND_OK_MARKER}\\n'`;
499
+ const marked = `set +x\nset -eu\n${cmd}\nprintf '\\n${COMMAND_OK_MARKER}\\n'`;
500
500
  const args: ExecCommandArgs = {
501
- cmd: `env -u BASH_ENV bash --noprofile --norc -c ${shellQuote(marked)}`,
501
+ cmd: `set +x\nenv -u BASH_ENV bash --noprofile --norc -c ${shellQuote(marked)}`,
502
502
  yieldTimeMs: 120_000,
503
503
  maxOutputTokens: 4_000,
504
504
  };
@@ -4,6 +4,7 @@ import {
4
4
  parseExecBannerExitCode,
5
5
  parseExecBannerSessionId,
6
6
  } from "./exec-banner";
7
+ import { RoutingMutationOutcomeUnknownError } from "./routing/routing-session";
7
8
 
8
9
  const TURN_EXEC_YIELD_MS = 250;
9
10
  const TURN_WRITE_YIELD_MS = 250;
@@ -759,6 +760,34 @@ class TurnToolCancellationControllerImpl implements TurnToolCancellationControll
759
760
  correlationId,
760
761
  async () => await tool.invoke(runContext, cancellableInput, details),
761
762
  );
763
+ } catch (error) {
764
+ const retainedProcess =
765
+ !useRemoteOpCancellation && error instanceof RoutingMutationOutcomeUnknownError
766
+ ? error.retainedProcess
767
+ : null;
768
+ const processSession =
769
+ retainedProcess === null
770
+ ? null
771
+ : retainedProcessSession(cancellationSession, retainedProcess.providerSessionId);
772
+ if (retainedProcess && processSession) {
773
+ // The provider output remains rejected, but DB promotion is
774
+ // already durable. Preserve only the safe process locator and
775
+ // exact routing session so turn finalization can drain it; never
776
+ // invoke a helper through the mutable current route.
777
+ this.shellSessions.set(retainedProcess.providerSessionId, {
778
+ sessionId: retainedProcess.providerSessionId,
779
+ markerPath,
780
+ token,
781
+ runContext,
782
+ execInvoke: tool.invoke,
783
+ writeInvoke: this.rawWriteInvoke,
784
+ processSession,
785
+ identity: null,
786
+ identityValidated: false,
787
+ cancellation: null,
788
+ });
789
+ }
790
+ throw error;
762
791
  } finally {
763
792
  remoteExec?.settle();
764
793
  }
@@ -151,7 +151,10 @@ export function readVerifiedWorkspaceArchive(
151
151
  }
152
152
 
153
153
  function stdoutFromExecResult(value: unknown): string {
154
- if (typeof value === "string") return value;
154
+ if (typeof value === "string") {
155
+ const delimiter = /\r?\nOutput:\r?\n/u.exec(value);
156
+ return delimiter ? value.slice(delimiter.index + delimiter[0].length) : value;
157
+ }
155
158
  if (!value || typeof value !== "object") return "";
156
159
  const output = value as { stdout?: unknown; output?: unknown };
157
160
  return typeof output.stdout === "string"
@@ -39,6 +39,7 @@ import {
39
39
  PointerButton,
40
40
  type DesktopInputRequest,
41
41
  } from "@opengeni/agent-proto";
42
+ import { redactSensitiveText } from "@opengeni/contracts";
42
43
 
43
44
  import { sandboxCommandExitCode, sandboxCommandOutput, sandboxCommandStillRunning } from "./index";
44
45
  // `stripExecBanner` is the SAME pure helper recording.ts uses to recover the raw
@@ -328,7 +329,7 @@ export class SandboxComputer implements Computer {
328
329
  // produces empty bytes, and the retry loop eventually throws. The wire-level
329
330
  // backstop in computerCallNormalizingFetch is also in place as a second net.
330
331
  console.warn(
331
- `[SandboxComputer] action command did not finish before the ${ACTION_YIELD_MS}ms yield window — proceeding to screenshot: ${cmd}`,
332
+ `[SandboxComputer] action command did not finish before the ${ACTION_YIELD_MS}ms yield window — proceeding to screenshot: ${redactSensitiveText(cmd)}`,
332
333
  );
333
334
  return output;
334
335
  }
@@ -758,7 +759,9 @@ export class NativeDesktopComputer implements Computer {
758
759
  // Exhausted the budget (or hit a terminal denial): FAIL LOUD. Log the specific
759
760
  // reason so the failure is DIAGNOSABLE (not a silent blank the model misreads),
760
761
  // then rethrow — never return "".
761
- const reason = lastError instanceof Error ? lastError.message : String(lastError);
762
+ const reason = redactSensitiveText(
763
+ lastError instanceof Error ? lastError.message : String(lastError),
764
+ );
762
765
  console.warn(
763
766
  `[NativeDesktopComputer] screenshot failed after ${attempt} attempt(s): ${reason}`,
764
767
  );