Skip to content
Merged
4 changes: 3 additions & 1 deletion configs/platform/system/harnesses/gemini/harness.yml
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,9 @@ exec:
TALE_GATEWAY_TOKEN: '${gateway.token}'
# A subscription-key credential (the Gemini CLI's Google-account sign-in /
# Code Assist license) is the CLI's OAuth credentials blob, staged verbatim
# as its credentials file under the session HOME (/agent/.runtime/home).
# as its credentials file under the session HOME (/agent/.runtime/home). The
# platform removes the file when the turn settles, and before a turn that
# runs without the subscription, so no later exec in the session reads it.
subscription:
kind: staged-file
path: .runtime/home/.gemini/oauth_creds.json
2 changes: 1 addition & 1 deletion docs/de/platform/agents/harnesses.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ Gemini CLI ist die Ausnahme: Tale setzt eine seiner Unterhaltungen nie fort. Ein

Bei einem gespeicherten API-Schlüssel oder einer Deployment-Umgebungsvariable stellt Tale einen sitzungsgebundenen Gateway-Schlüssel bereit. Der ursprüngliche Modell-Provider-Schlüssel bleibt bei der Plattform. Gateway-Aufrufe werden gemessen und unterliegen den geltenden Ausgabenregeln. Bereits an andere laufende Durchläufe vergebene Beträge werden berücksichtigt.

Provider-Abonnements verwenden ihre unterstützte Agent-Laufzeit und erhalten den Abonnement-Zugang in der Sitzungsumgebung. Sie dienen weder als normale Chat-Zugangsdaten noch für inkompatible Agent-Laufzeiten, weil die Anbieter Abo-Tokens nur in ihrer eigenen Laufzeit erlauben; mehr dazu unter [KI-Anbieter](/de/platform/admin/providers#abos-in-aufgaben-nutzen-nicht-im-chat). Ihre direkten Aufrufe umgehen die Kostenmessung und Ausgabengrenzen des Tale-Gateways. Prüfe die Nutzung beim Abonnement-Provider.
Provider-Abonnements verwenden ihre unterstützte Agent-Laufzeit und erhalten den Abonnement-Zugang in der Sitzungsumgebung. Gemini ist die Ausnahme: Seine Google-Anmeldung wird für den Durchlauf als Datei im Home-Ordner der Sitzung abgelegt und am Ende des Durchlaufs wieder entfernt. Sie dienen weder als normale Chat-Zugangsdaten noch für inkompatible Agent-Laufzeiten, weil die Anbieter Abo-Tokens nur in ihrer eigenen Laufzeit erlauben; mehr dazu unter [KI-Anbieter](/de/platform/admin/providers#abos-in-aufgaben-nutzen-nicht-im-chat). Ihre direkten Aufrufe umgehen die Kostenmessung und Ausgabengrenzen des Tale-Gateways. Prüfe die Nutzung beim Abonnement-Provider.

Diese Regeln für Modellzugänge bedeuten nicht, dass die Sandbox keinerlei Geheimnisse enthält. Ausdrücklich vergebene **Secrets** sowie ein Token für einen zugeordneten GitHub-Zugang können darin verfügbar sein. Vergib nur den für die Aufgabe nötigen Zugriff.

Expand Down
2 changes: 1 addition & 1 deletion docs/en/platform/agents/harnesses.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ Gemini CLI is the exception: Tale never continues one of its conversations. A la

With a stored API key or deployment-environment credential, Tale supplies a session-scoped gateway key. The original model-provider key stays at the platform. Calls through this gateway are metered and subject to the applicable spending rules, including allowance already assigned to other running turns.

Vendor subscriptions use their supported agent runtime and receive the subscription credential in the session environment. They cannot be used as ordinary chat credentials or with an incompatible agent runtime, because the vendors permit subscription tokens only in their own runtime; [AI providers](/platform/admin/providers#use-subscriptions-in-tasks-not-in-chat) explains this. Their direct provider calls bypass Tale’s gateway metering and spending caps; review usage with the subscription provider.
Vendor subscriptions use their supported agent runtime and receive the subscription credential in the session environment. Gemini is the exception: its Google sign-in is written to a file in the session’s home folder for the turn, and removed when the turn ends. They cannot be used as ordinary chat credentials or with an incompatible agent runtime, because the vendors permit subscription tokens only in their own runtime; [AI providers](/platform/admin/providers#use-subscriptions-in-tasks-not-in-chat) explains this. Their direct provider calls bypass Tale’s gateway metering and spending caps; review usage with the subscription provider.

These model-credential rules do not mean the sandbox contains no secrets. Explicitly granted **Secrets**, and the token supplied for an equipped GitHub connection, can be available inside it. Grant only the access the task needs.

Expand Down
2 changes: 1 addition & 1 deletion docs/fr/platform/agents/harnesses.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ Gemini CLI fait exception : Tale ne poursuit jamais une de ses conversations. Un

Avec une clé API stockée ou fournie par l’environnement du déploiement, Tale remet une clé de passerelle limitée à la session. La clé d’origine du fournisseur de modèle reste dans la plateforme. Les appels de passerelle sont mesurés et soumis aux règles de dépense applicables, en tenant compte des montants déjà attribués aux autres échanges en cours.

Les abonnements fournisseurs utilisent leur environnement d’agent compatible et reçoivent les identifiants d’abonnement dans l’environnement de la session. Ils ne servent pas d’identifiants de chat ordinaire et ne fonctionnent pas avec un environnement d’agent incompatible, car les fournisseurs n’autorisent les jetons d’abonnement que dans leur propre environnement ; la page [Fournisseurs IA](/fr/platform/admin/providers#utiliser-les-abonnements-dans-les-taches-pas-dans-le-chat) l’explique. Leurs appels directs échappent à la mesure et aux plafonds de la passerelle Tale. Examine la consommation auprès du fournisseur d’abonnement.
Les abonnements fournisseurs utilisent leur environnement d’agent compatible et reçoivent les identifiants d’abonnement dans l’environnement de la session. Gemini fait exception : sa connexion Google est écrite dans un fichier du dossier personnel de la session pour l’échange, puis supprimée à la fin de l’échange. Ils ne servent pas d’identifiants de chat ordinaire et ne fonctionnent pas avec un environnement d’agent incompatible, car les fournisseurs n’autorisent les jetons d’abonnement que dans leur propre environnement ; la page [Fournisseurs IA](/fr/platform/admin/providers#utiliser-les-abonnements-dans-les-taches-pas-dans-le-chat) l’explique. Leurs appels directs échappent à la mesure et aux plafonds de la passerelle Tale. Examine la consommation auprès du fournisseur d’abonnement.

Ces règles sur les identifiants de modèle ne signifient pas que la sandbox ne contient aucun secret. Les **Secrets** explicitement accordés et le jeton d’un accès GitHub équipé peuvent y être disponibles. Limite ces accès aux besoins de la tâche.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@
* workflow went on as if the agent had deliberately changed nothing. A turn
* that only called a tool — the question tool included, which parks the run
* instead — still ends as before.
*
* The same drive also takes a Gemini turn's staged subscription credential
* out of the run's session when the turn settles, and leaves it to a newer
* exec the run moved on to.
*/

import { beforeEach, describe, expect, it, vi } from 'vitest';
Expand All @@ -23,6 +27,10 @@ const io = vi.hoisted(() => ({
/** What the exec printed, replayed into every drain window. */
stdout: '',
cancelled: [] as string[],
/** What each settle asked the output harvest for. */
harvests: [] as Array<Record<string, unknown>>,
/** Every path set the session was asked to delete, in order. */
deletes: [] as string[][],
}));

vi.mock('../node_only/sandbox/helpers/session_client', async (importActual) => {
Expand All @@ -32,6 +40,8 @@ vi.mock('../node_only/sandbox/helpers/session_client', async (importActual) => {
>();
return {
...actual,
// The window closes a Claude turn's held stdin; no spawner answers here.
sessionWriteExecStdin: async () => ({ ok: true }),
sessionGetExecCheckpoint: async () => null,
sessionPutExecCheckpoint: async () => undefined,
drainSessionExecResilient: async (
Expand All @@ -54,10 +64,20 @@ vi.mock('../node_only/sandbox/helpers/session_client', async (importActual) => {
io.cancelled.push(execId);
return true;
},
sessionDeleteFiles: async (_sessionId: string, paths: string[]) => {
io.deletes.push(paths);
return { deleted: paths, skipped: [] };
},
};
});
vi.mock('../node_only/sandbox/session_exec', () => ({
harvestSessionOutput: async () => ({ files: [], harvestSkipped: [] }),
harvestSessionOutput: async (
_ctx: unknown,
args: Record<string, unknown>,
) => {
io.harvests.push(args);
return { files: [], harvestSkipped: [] };
},
}));

const { driveWorkflowAgentTurnImpl } = await import('./agent_host');
Expand Down Expand Up @@ -89,7 +109,9 @@ interface Call {
args: Record<string, unknown>;
}

function makeCtx(opts: { pendingAsk?: typeof ASK } = {}) {
function makeCtx(
opts: { pendingAsk?: typeof ASK; cursorExecId?: string } = {},
) {
const mutations: Call[] = [];
const ctx = {
runQuery: async (ref: unknown) => {
Expand All @@ -100,7 +122,7 @@ function makeCtx(opts: { pendingAsk?: typeof ASK } = {}) {
cursor: {
node: KEYS.nodeId,
agent: {
execId: KEYS.execId,
execId: opts.cursorExecId ?? KEYS.execId,
sessionId: KEYS.sessionId,
deadlineAt: KEYS.deadlineAt,
providerSlug: KEYS.providerSlug,
Expand Down Expand Up @@ -160,6 +182,8 @@ const SILENT_RESULT = {
beforeEach(() => {
io.stdout = '';
io.cancelled = [];
io.harvests = [];
io.deletes = [];
vi.spyOn(console, 'warn').mockImplementation(() => {});
vi.spyOn(console, 'error').mockImplementation(() => {});
});
Expand Down Expand Up @@ -190,6 +214,10 @@ describe('an automation agent turn', () => {
.filter((text): text is string => typeof text === 'string');
expect(progress.length).toBeGreaterThan(0);
expect(progress.every((text) => text.length <= 64 * 1024)).toBe(true);
// The exec exited before the settle: the harvest takes its first listing.
expect(io.harvests).toEqual([
expect.objectContaining({ execId: KEYS.execId, execExited: true }),
]);
});

it('fails, retryably, when the model answered nothing', async () => {
Expand Down Expand Up @@ -315,3 +343,26 @@ describe('an automation agent turn', () => {
});
});
});

describe('a Gemini automation turn’s staged subscription credential', () => {
const GEMINI = { ...KEYS, harness: 'gemini' };
const CREDENTIAL = ['.runtime/home/.gemini/oauth_creds.json'];

it('leaves the session when the turn settles', async () => {
const { ctx, mutations } = makeCtx();

await driveWorkflowAgentTurnImpl(ctx, GEMINI);

expect(called(mutations, 'recordAgentTurnSettled')).toHaveLength(1);
expect(io.deletes).toEqual([CREDENTIAL]);
});

it('stays for the newer exec the run moved on to', async () => {
const { ctx } = makeCtx({ cursorExecId: 'exec-2' });

await driveWorkflowAgentTurnImpl(ctx, GEMINI);

expect(io.cancelled).toEqual([KEYS.execId]);
expect(io.deletes).toEqual([]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ vi.mock('../node_only/sandbox/helpers/session_client', async (importActual) => {
>();
return {
...actual,
// The window closes a Claude turn's held stdin; no spawner answers here.
sessionWriteExecStdin: async () => ({ ok: true }),
sessionGetExecCheckpoint: async () => null,
sessionPutExecCheckpoint: async () => undefined,
drainSessionExecResilient: async (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ vi.mock('../node_only/sandbox/helpers/session_client', async (importActual) => {
>();
return {
...actual,
// The window closes a Claude turn's held stdin; no spawner answers here.
sessionWriteExecStdin: async () => ({ ok: true }),
sessionGetExecCheckpoint: async () => null,
sessionPutExecCheckpoint: async () => undefined,
sessionCancelExec: async (_sessionId: string, execId: string) => {
Expand Down
42 changes: 37 additions & 5 deletions services/platform/backend/core/automations/agent_host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import {
harnessMountsMcp,
harnessResumesConversations,
isManagedHarness,
removeStagedSubscription,
resolveHarnessTurnContextWindow,
SKILLS_DIR,
} from '../chat/external_turn_shared';
Expand Down Expand Up @@ -1721,6 +1722,23 @@ export function isWorkflowTurnLive(
);
}

/** Whether a newer agent exec of a live run holds its session now (a retry
* of the node, or the next agent node): that exec stages its own
* subscription credential, so an older turn must leave the file alone. */
function heldByNewerExec(
state: AgentCursorState | null,
execId: string,
): boolean {
const agent = state?.cursor?.agent;
return (
state !== null &&
LIVE_RUN_STATUSES.has(state.status) &&
agent !== undefined &&
agent.execId !== execId &&
agent.result === undefined
);
}

/**
* Why a scheduled start must not run, or null when it may. Deliberately
* LENIENT where `isWorkflowTurnLive` is strict: the kick enqueues the start
Expand Down Expand Up @@ -1841,6 +1859,9 @@ export async function driveWorkflowAgentTurnImpl(
execId: args.execId,
status: 'cancelled',
});
if (!heldByNewerExec(state, args.execId)) {
await removeStagedSubscription(args.sessionId, args.harness);
}
// The run went terminal while this turn was live, so the terminal
// hooks' hibernate skipped past it — the op is terminal now.
await ctx
Expand Down Expand Up @@ -2572,6 +2593,8 @@ async function continueOrSettle(
? { exitCode: window.execResult.exitCode }
: {}),
});
// The turn that answers the question stages its credential again.
await removeStagedSubscription(args.sessionId, args.harness);
return;
}
if (pendingAsk !== null) {
Expand Down Expand Up @@ -2636,6 +2659,7 @@ async function continueOrSettle(
? { agentResultStatus: ended.status }
: {}),
harvest: true,
execExited: window.exited,
},
);
}
Expand All @@ -2654,6 +2678,9 @@ async function settleWorkflowAgentTurn(
exitCode?: number;
agentResultStatus?: string;
harvest?: boolean;
/** The turn's exec exited on its own before the settle, so the harvest
* reads a box nothing is still writing to. */
execExited?: boolean;
} = {},
): Promise<void> {
const release = await releaseTurnKey(ctx, {
Expand All @@ -2674,23 +2701,27 @@ async function settleWorkflowAgentTurn(
// write. The record has its own first-wins gate, so finishing the dead
// winner's job is safe: when the result is already there this returns
// without touching anything.
const state = await ctx.runQuery(
internal.automations.queries.readAgentCursor,
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- carried verbatim from the turn's own args
{ organizationId: args.organizationId, runId: args.runId as never },
);
const state = await readCursorState(ctx, args);
const agent = state?.cursor?.agent;
if (
agent === undefined ||
agent.execId !== args.execId ||
agent.result !== undefined
) {
// Settled by someone else (a cancel, the watchdog), but this turn's
// exec is over all the same.
if (!heldByNewerExec(state, args.execId)) {
await removeStagedSubscription(args.sessionId, args.harness);
}
return;
}
console.warn(
`[agent-host] finalize claim for ${args.execId} was burned with no recorded result — completing the dead settle's record`,
);
}
// The turn is over: its staged subscription credential leaves the session
// with it.
await removeStagedSubscription(args.sessionId, args.harness);

// The broker account that served this exec, when one did: a 429 cools it
// down, and a 401 on it is the broker refreshing the account under the
Expand Down Expand Up @@ -2740,6 +2771,7 @@ async function settleWorkflowAgentTurn(
organizationId: args.organizationId,
sessionId: args.sessionId,
execId: args.execId,
...(opts.execExited === true ? { execExited: true } : {}),
});
files = harvested.files.map((file) => ({
name: file.path.split('/').at(-1) ?? file.path,
Expand Down
23 changes: 23 additions & 0 deletions services/platform/backend/core/automations/script_host.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,9 +145,11 @@ describe('workflowScriptRunner', () => {
packagesByLang: { python: ['openpyxl'] },
timeoutMs: DEFAULT_SCRIPT_TIMEOUT_MS,
});
// The script's exec has exited: the harvest takes its first listing.
expect(harvestSessionOutput).toHaveBeenCalledWith(ctx, {
organizationId: ORG,
sessionId: 'wf-session',
execExited: true,
});
expect(sessionReadFile).toHaveBeenCalledWith(
'wf-session',
Expand Down Expand Up @@ -309,4 +311,25 @@ describe('workflowScriptRunner', () => {
{ path: '/agent/output/huge.bin', reason: 'over cap' },
]);
});

it('lets the harvest re-read an empty box after a script cut at its timeout', async () => {
// A cut exec's processes can still be inside their kill grace.
runStepsInSession.mockResolvedValue({
status: 'cancelled',
exitCode: null,
stdout: '',
stderr: '',
});
await workflowScriptRunner(ctx)({
organizationId: ORG,
runId: RUN,
skill: 's',
entry: 'a.py',
});
expect(harvestSessionOutput).toHaveBeenCalledWith(ctx, {
organizationId: ORG,
sessionId: 'wf-session',
execExited: false,
});
});
});
3 changes: 3 additions & 0 deletions services/platform/backend/core/automations/script_host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,9 @@ export function workflowScriptRunner(
const { files, harvestSkipped } = await harvestSessionOutput(ctx, {
organizationId: run.organizationId,
sessionId,
// Every step's exec has ended by now; one cut at its timeout may still
// have processes inside their kill grace.
execExited: exec.status !== 'cancelled',
});

// The result file is the script's own verdict — read it back directly so
Expand Down
Loading
Loading