Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions helm/codeapi/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -429,6 +429,13 @@ egressGateway:
console-staging.optale.com:
httpsPassthrough: true
httpsPassthroughTotalTimeoutMs: 300000
# The Console API the workspace CLI calls (OPT-1586): its own header wait and
# per-run budget, metered apart from internet fetches. Stays under the sandbox
# proxy's 60 s idle cap so a slow call ends in a named FETCH_TIMEOUT.
httpsPassthroughLimits:
headersTimeoutMs: 55000
maxFetchesPerGrant: 500
maxAggregateBytesPerGrant: 268435456
# W730 §3 PENDING rows — do NOT widen this policy by inference. Each row
# needs PRIMARY's rev-B journey evidence before it becomes a config entry:
# 1. image attachment MIME on this host — PENDING
Expand Down
7 changes: 6 additions & 1 deletion service/config/external-fetch-policy.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,12 @@
},
"console-staging.optale.com": {
"httpsPassthrough": true,
"httpsPassthroughTotalTimeoutMs": 300000
"httpsPassthroughTotalTimeoutMs": 300000,
"httpsPassthroughLimits": {
"headersTimeoutMs": 55000,
"maxFetchesPerGrant": 500,
"maxAggregateBytesPerGrant": 268435456
}
}
}
}
68 changes: 68 additions & 0 deletions service/src/egress-gateway.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2210,4 +2210,72 @@ describe('egress gateway routes', () => {
},
);

test('accepts more passthroughs than the grant fetch budget for a host with its own budget', async () => {
// service/config/external-fetch-policy.json grants console-staging.optale.com its own budget
// (httpsPassthroughLimits) while the grant-wide fetch budget stays 8.
const policyPath = path.resolve(__dirname, '../config/external-fetch-policy.json');
env.EXTERNAL_FETCH_POLICY_FILE = policyPath;
const policy = parseExternalFetchPolicy(JSON.parse(fs.readFileSync(policyPath, 'utf8')));
expect(policy.limits.maxFetchesPerGrant).toBe(8);
const host = 'console-staging.optale.com';
const grant = claims({
network_policy: serializeExternalFetchPolicy(policy),
network_policy_digest: externalFetchPolicyDigest(policy),
grant_id: 'grant_console_budget',
});
const redis = new RedisMock();
env.EGRESS_LEDGER_REQUIRED = true;
setEgressLedgerRedisForTest(redis);
await createEgressLedger(grant);
setHttpsPassthroughOpenForTest(async args => {
const target = validateHttpsPassthroughUrl(args.url, args.policy, args.method);
await args.beforeRequest?.(target);
const response = Object.assign(Readable.from([Buffer.from('{"ok":true}')]), {
statusCode: 200,
headers: { 'content-type': 'application/json' },
rawHeaders: ['Content-Type', 'application/json'],
complete: false,
}) as unknown as IncomingMessage;
return {
response,
target,
responseBytes: 0,
redirects: 0,
timedOut: () => false,
close: () => response.destroy(),
};
});
try {
const envelope = JSON.stringify({
url: `https://${host}/api/optale/mcp`,
method: 'POST',
headers: {},
bodyBase64: '',
});
for (let i = 0; i < 12; i += 1) {
const result = await gatewayPostWithTrailers(
'/https-passthrough',
{ ...grantHeader(grant), 'Content-Type': 'application/json' },
envelope,
);
expect(result.status).toBe(200);
expect(result.body).toBe('{"ok":true}');
}
const ledger = await assertEgressGrantActive(grant);
expect(ledger.fetch_count).toBe(0);
// Every call settled its byte reservation (the success path commits it) in the host budget.
expect(ledger.passthrough_budgets[host]).toEqual({
fetch_count: 12,
fetched_bytes: 12 * '{"ok":true}'.length,
fetch_reservations: {},
});
expect(ledger.fetched_bytes).toBe(0);
expect(ledger.request_count).toBe(12);
} finally {
env.EGRESS_LEDGER_REQUIRED = false;
setEgressLedgerRedisForTest(null);
await redis.quit();
}
});

});
16 changes: 14 additions & 2 deletions service/src/egress-gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1890,6 +1890,7 @@ app.post('/https-passthrough', async (req, res) => {
let reservedBytes = 0;
let reservationId: string | undefined;
let opened: OpenHttpsPassthroughResponse | undefined;
let passthroughHost: string | undefined;
try {
let envelope: HttpsPassthroughEnvelope;
try {
Expand All @@ -1912,10 +1913,17 @@ app.post('/https-passthrough', async (req, res) => {
beforeRequest: async target => {
await consumeEgressFetchAttempt({
grant: activeGrant,
maxFetches: target.policy.limits.maxFetchesPerGrant,
...(target.policy.httpsPassthroughLimits
? {
maxFetches: target.policy.httpsPassthroughLimits.maxFetchesPerGrant,
passthroughHost: target.host,
}
: { maxFetches: target.policy.limits.maxFetchesPerGrant }),
});
},
});
const ownBudget = opened.target.policy.httpsPassthroughLimits;
passthroughHost = ownBudget ? opened.target.host : undefined;
const fields = auditFields(res);
fields.destinationHost = opened.target.host;
fields.pathHash = opened.target.pathHash;
Expand Down Expand Up @@ -1951,7 +1959,9 @@ app.post('/https-passthrough', async (req, res) => {
? declaredBytes
: opened.target.policy.limits.maxResponseBytes,
maxAggregateBytes:
ownBudget?.maxAggregateBytesPerGrant ??
opened.target.policy.limits.maxAggregateBytesPerGrant,
passthroughHost,
});
if (declaredBytes !== undefined && declaredBytes > reservedBytes) {
opened.close();
Expand All @@ -1972,6 +1982,7 @@ app.post('/https-passthrough', async (req, res) => {
reservationId,
reservedBytes,
responseBytes,
passthroughHost,
});
reservedBytes = 0;
fields.responseBytes = responseBytes;
Expand All @@ -1991,6 +2002,7 @@ app.post('/https-passthrough', async (req, res) => {
reservationId,
reservedBytes,
responseBytes,
passthroughHost,
});
reservedBytes = 0;
} catch {
Expand Down Expand Up @@ -2023,7 +2035,7 @@ app.post('/https-passthrough', async (req, res) => {
} finally {
if (grant && reservationId && reservedBytes > 0) {
try {
await egressFetchRelease({ grant, reservationId, reservedBytes });
await egressFetchRelease({ grant, reservationId, reservedBytes, passthroughHost });
} catch {
logger.error(
'Failed to release HTTPS passthrough byte reservation',
Expand Down
44 changes: 44 additions & 0 deletions service/src/egress-ledger.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,50 @@ describe('egress Redis ledger', () => {
expect(record.fetch_count).toBe(1);
});

test('meters a passthrough host with its own limits apart from the grant-wide fetch budget', async () => {
const claims = grant({ max_requests: 100 });
await createEgressLedger(claims);

for (let i = 0; i < 20; i += 1) {
await consumeEgressFetchAttempt({ grant: claims, maxFetches: 50, passthroughHost: 'console.test' });
}
// Twenty Console calls leave the internet-fetch budget of 8 untouched.
for (let i = 0; i < 8; i += 1) {
await consumeEgressFetchAttempt({ grant: claims, maxFetches: 8 });
}
await expectFetchError(
() => consumeEgressFetchAttempt({ grant: claims, maxFetches: 8 }),
'FETCH_BUDGET_EXCEEDED',
);
// An exhausted internet budget does not stop the Console host.
await consumeEgressFetchAttempt({ grant: claims, maxFetches: 50, passthroughHost: 'console.test' });

const reserved = await reserveEgressFetchBytes({
grant: claims,
reservationId: 'console-reservation',
maxBytes: 60,
maxAggregateBytes: 100,
passthroughHost: 'console.test',
});
await commitEgressFetchBytes({
grant: claims,
reservationId: 'console-reservation',
reservedBytes: reserved,
responseBytes: 25,
passthroughHost: 'console.test',
});

const record = await assertEgressGrantActive(claims);
expect(record.fetch_count).toBe(8);
expect(record.fetched_bytes).toBe(0);
expect(record.passthrough_budgets['console.test']).toEqual({
fetch_count: 21,
fetched_bytes: 25,
fetch_reservations: {},
});
expect(record.request_count).toBe(29);
});

test('reserves aggregate bytes, commits actual bytes, and releases failed reservations', async () => {
const claims = grant({ max_requests: 20 });
await createEgressLedger(claims);
Expand Down
83 changes: 62 additions & 21 deletions service/src/egress-ledger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,19 @@ import { redisKeepAliveOptions } from './redis-options';

type LedgerStatus = 'active' | 'revoked';

export interface EgressLedgerRecord {
/** Requests and response bytes metered against one fetch budget. */
export interface EgressFetchBudget {
fetch_count: number;
fetched_bytes: number;
fetch_reservations: Record<string, number>;
}

/**
* The record's own fetch fields are the grant-wide external-fetch budget. A passthrough host with
* its own limits is metered in `passthrough_budgets[host]` instead, so Console API calls from the
* workspace CLI neither use up nor are capped by the internet-fetch budget.
*/
export interface EgressLedgerRecord extends EgressFetchBudget {
grant_id: string;
exec_id: string;
status: LedgerStatus;
Expand All @@ -28,9 +40,7 @@ export interface EgressLedgerRecord {
upload_count: number;
tool_call_count: number;
uploaded_bytes: number;
fetch_count: number;
fetched_bytes: number;
fetch_reservations: Record<string, number>;
passthrough_budgets: Record<string, EgressFetchBudget>;
output_file_ids: string[];
}

Expand Down Expand Up @@ -175,6 +185,7 @@ function recordFromGrant(grant: EgressGrantClaims): EgressLedgerRecord {
fetch_count: 0,
fetched_bytes: 0,
fetch_reservations: {},
passthrough_budgets: {},
uploaded_bytes: 0,
output_file_ids: [],
};
Expand Down Expand Up @@ -207,19 +218,38 @@ export async function ensureEgressLedger(grant: EgressGrantClaims): Promise<void
);
}

function isPlainObject(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}

function normalizeBudget<T extends EgressFetchBudget>(budget: T): T {
if (!Number.isSafeInteger(budget.fetch_count) || budget.fetch_count < 0) budget.fetch_count = 0;
if (!Number.isSafeInteger(budget.fetched_bytes) || budget.fetched_bytes < 0) budget.fetched_bytes = 0;
if (!isPlainObject(budget.fetch_reservations)) budget.fetch_reservations = {};
return budget;
}

function normalizeRecord(record: EgressLedgerRecord): EgressLedgerRecord {
if (!Number.isSafeInteger(record.fetch_count) || record.fetch_count < 0) record.fetch_count = 0;
if (!Number.isSafeInteger(record.fetched_bytes) || record.fetched_bytes < 0) record.fetched_bytes = 0;
if (
!record.fetch_reservations
|| typeof record.fetch_reservations !== 'object'
|| Array.isArray(record.fetch_reservations)
) {
record.fetch_reservations = {};
normalizeBudget(record);
if (!isPlainObject(record.passthrough_budgets)) record.passthrough_budgets = {};
for (const [host, budget] of Object.entries(record.passthrough_budgets)) {
record.passthrough_budgets[host] = normalizeBudget(
(isPlainObject(budget) ? budget : {}) as unknown as EgressFetchBudget,
);
}
return record;
}

/** The grant-wide budget, or the named passthrough host's own budget. */
function fetchBudget(record: EgressLedgerRecord, passthroughHost: string | undefined): EgressFetchBudget {
if (passthroughHost === undefined) return record;
const existing = record.passthrough_budgets[passthroughHost];
if (existing) return existing;
const created: EgressFetchBudget = { fetch_count: 0, fetched_bytes: 0, fetch_reservations: {} };
record.passthrough_budgets[passthroughHost] = created;
return created;
}

async function loadRecord(grantId: string): Promise<EgressLedgerRecord> {
const raw = await redisConnection().get(ledgerKey(grantId));
if (!raw) {
Expand Down Expand Up @@ -350,16 +380,22 @@ export async function recordEgressToolCall(grantId: string | undefined, executio
});
}

/**
* `passthroughHost` names a passthrough host whose policy carries its own limits; its requests and
* bytes are then metered in that host's budget. Omitted, the grant-wide budget is used.
*/
export async function consumeEgressFetchAttempt(args: {
grant: EgressGrantClaims;
maxFetches: number;
passthroughHost?: string;
}): Promise<EgressLedgerRecord> {
return mutateRecord(args.grant, record => {
if (!Number.isSafeInteger(args.maxFetches) || args.maxFetches < 1 || record.fetch_count >= args.maxFetches) {
const budget = fetchBudget(record, args.passthroughHost);
if (!Number.isSafeInteger(args.maxFetches) || args.maxFetches < 1 || budget.fetch_count >= args.maxFetches) {
throw new ExternalFetchError('FETCH_BUDGET_EXCEEDED');
}
record.request_count += 1;
record.fetch_count += 1;
budget.fetch_count += 1;
});
}

Expand All @@ -368,13 +404,15 @@ export async function reserveEgressFetchBytes(args: {
reservationId: string;
maxBytes: number;
maxAggregateBytes: number;
passthroughHost?: string;
}): Promise<number> {
if (!/^[A-Za-z0-9_-]{1,128}$/.test(args.reservationId)) {
throw new ExternalFetchError('FETCH_FAILED');
}
let reservedBytes = 0;
await mutateRecord(args.grant, record => {
const existing = record.fetch_reservations[args.reservationId];
const budget = fetchBudget(record, args.passthroughHost);
const existing = budget.fetch_reservations[args.reservationId];
if (existing !== undefined) {
reservedBytes = existing;
return;
Expand All @@ -387,11 +425,11 @@ export async function reserveEgressFetchBytes(args: {
) {
throw new ExternalFetchError('FETCH_BUDGET_EXCEEDED');
}
const remaining = args.maxAggregateBytes - record.fetched_bytes;
const remaining = args.maxAggregateBytes - budget.fetched_bytes;
if (remaining < 1) throw new ExternalFetchError('FETCH_BUDGET_EXCEEDED');
reservedBytes = Math.min(args.maxBytes, remaining);
record.fetched_bytes += reservedBytes;
record.fetch_reservations[args.reservationId] = reservedBytes;
budget.fetched_bytes += reservedBytes;
budget.fetch_reservations[args.reservationId] = reservedBytes;
});
return reservedBytes;
}
Expand All @@ -401,6 +439,7 @@ export async function commitEgressFetchBytes(args: {
reservationId: string;
reservedBytes: number;
responseBytes: number;
passthroughHost?: string;
}): Promise<void> {
if (
!/^[A-Za-z0-9_-]{1,128}$/.test(args.reservationId)
Expand All @@ -413,20 +452,22 @@ export async function commitEgressFetchBytes(args: {
throw new ExternalFetchError('FETCH_FAILED');
}
await mutateRecord(args.grant, record => {
const reservedBytes = record.fetch_reservations[args.reservationId];
const budget = fetchBudget(record, args.passthroughHost);
const reservedBytes = budget.fetch_reservations[args.reservationId];
if (reservedBytes === undefined) return;
if (reservedBytes !== args.reservedBytes) {
throw new ExternalFetchError('FETCH_FAILED');
}
record.fetched_bytes = Math.max(0, record.fetched_bytes - (reservedBytes - args.responseBytes));
delete record.fetch_reservations[args.reservationId];
budget.fetched_bytes = Math.max(0, budget.fetched_bytes - (reservedBytes - args.responseBytes));
delete budget.fetch_reservations[args.reservationId];
});
}

export async function releaseEgressFetchBytes(args: {
grant: EgressGrantClaims;
reservationId: string;
reservedBytes: number;
passthroughHost?: string;
}): Promise<void> {
await commitEgressFetchBytes({ ...args, responseBytes: 0 });
}
Expand Down
Loading
Loading