Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
9c2a5f7
fix(agent): stop resolving prototype members as model aliases
kojiwakayama Aug 2, 2026
2a1c87c
fix(agent): bind resolved project identity to the requested reference
kojiwakayama Aug 2, 2026
f26d2f7
fix(agent): bound tool error text and fail closed on hostile values
kojiwakayama Aug 2, 2026
ab189e2
fix(workflow): stop reporting success for runs with no durable outcome
kojiwakayama Aug 2, 2026
a78c917
fix(agent): detach failed project lookup cleanup
kojiwakayama Aug 3, 2026
155690c
fix(agent): avoid proxy traps in tool error formatting
kojiwakayama Aug 3, 2026
85ff497
fix(agent): fail closed on edge proxy errors
kojiwakayama Aug 3, 2026
93932ee
fix(agent): harden structured error snapshots
kojiwakayama Aug 3, 2026
5d1dca6
fix(agent): close snapshot hook gaps
kojiwakayama Aug 3, 2026
ba159fe
Keep provider diagnostics bounded without Proxy guarantees
kojiwakayama Aug 3, 2026
6503a45
Keep extension request builders inside the full lint gate
kojiwakayama Aug 3, 2026
2d972c6
Fail closed on hostile project lookup payloads
kojiwakayama Aug 3, 2026
7a5c237
fix(agent): fail closed without proxy brand checks
kojiwakayama Aug 3, 2026
b70b813
Fail closed when hosted lookup cannot identify proxies
kojiwakayama Aug 3, 2026
8dbfecd
fix(agent): address runtime hardening review
kojiwakayama Aug 3, 2026
d02aed9
Keep child lifecycle assertions aligned with run ownership
kojiwakayama Aug 3, 2026
b989d4e
Preserve run diagnostics around child workflow edge states
kojiwakayama Aug 3, 2026
d029142
Preserve safe diagnostics in review warning paths
kojiwakayama Aug 3, 2026
bafe588
Fail closed on edge MCP config reflection
kojiwakayama Aug 3, 2026
514d4e9
Prevent provider snapshot option traps before inspection
kojiwakayama Aug 3, 2026
19a2998
Keep edge MCP tools loadable without Proxy brands
kojiwakayama Aug 3, 2026
2852774
fix(agent): close remaining diagnostic reflection gaps
kojiwakayama Aug 3, 2026
59dbe98
Keep provider replay metadata working on edge runtimes
kojiwakayama Aug 3, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import {
jsonValuesEqual,
readProviderOptions,
readRecord,
stringifyJsonValue,
stringifyToolResultValue,
unwrapToolInputSchema,
} from "veryfront/provider/shared";
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import {
jsonValuesEqual,
readProviderOptions,
stringifyJsonValue,
stringifyToolArguments,
stringifyToolResultValue,
unwrapToolInputSchema,
Expand Down
21 changes: 21 additions & 0 deletions src/agent/hosted/child-tool-input.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
resolveHostedChildForkThinkingOverride,
withHostedChildInvocationContext,
} from "./child-tool-input.ts";
import { MAX_OPAQUE_ID_CODE_UNITS } from "#veryfront/utils/project-identity.ts";

Deno.test("hostedChildForkToolInputSchema accepts the hosted child fork fields", () => {
const parsed = hostedChildForkToolInputSchema.parse({
Expand Down Expand Up @@ -143,6 +144,26 @@ Deno.test("hostedChildForkToolInputSchema rejects negative thinking budgets", ()
assertEquals(result.success, false);
});

Deno.test("hostedChildForkToolInputSchema rejects unsafe project references", () => {
for (
const projectReference of [
"",
" project-123",
"project-\n123",
"p".repeat(MAX_OPAQUE_ID_CODE_UNITS + 1),
]
) {
const result = hostedChildForkToolInputSchema.safeParse({
description: "invalid project",
prompt: "Try an invalid project reference.",
context: {},
project_reference: projectReference,
});

assertEquals(result.success, false);
}
});

Deno.test("DEFAULT_HOSTED_CHILD_AGENT_ID names the hosted child runtime agent", () => {
assertEquals(DEFAULT_HOSTED_CHILD_AGENT_ID, "invoke-agent-child");
});
Expand Down
19 changes: 16 additions & 3 deletions src/agent/hosted/child-tool-input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ import type { InferSchema } from "#veryfront/extensions/schema/index.ts";
import { withDefaultResearchArtifactPath } from "../artifacts/default-research-artifact-policy.ts";
import type { ChildRunResultMode } from "../child-run/result-summary.ts";
import type { RuntimeAgentThinkingConfig } from "../runtime/agent-definition.ts";
import {
isCanonicalOpaqueProjectIdentifier,
MAX_OPAQUE_ID_CODE_UNITS,
} from "#veryfront/utils/project-identity.ts";

/** Default value for hosted child agent ID. */
export const DEFAULT_HOSTED_CHILD_AGENT_ID = "invoke-agent-child";
Expand All @@ -19,9 +23,18 @@ export const getHostedChildForkToolInputSchema = defineSchema((v) =>
context: v.record(v.string(), getJsonValueSchema()).default({}).describe(
"Structured data payload for the child task. Use this for critical facts, records, ids, decisions, and values the child must act on. Defaults to {} when the delegation has no record or evidence payload.",
),
project_reference: v.string().optional().describe(
"Override project context by UUID or slug. Use after studio_open_project.",
),
project_reference: v
.string()
.min(1)
.max(MAX_OPAQUE_ID_CODE_UNITS)
.refine(
isCanonicalOpaqueProjectIdentifier,
"project_reference must be trimmed and contain no control characters",
)
.optional()
.describe(
"Override project context by UUID or slug. Use after studio_open_project.",
),
tools: v.array(v.string()).optional().describe(
"Tool subset for this fork. Omit = inherit all parent tools.",
),
Expand Down
244 changes: 244 additions & 0 deletions src/agent/hosted/project-reference-resolver.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
import "#veryfront/schemas/_test-setup.ts";
import { assertEquals, assertRejects } from "#veryfront/testing/assert.ts";
import { MAX_OPAQUE_ID_CODE_UNITS } from "#veryfront/utils/project-identity.ts";
import { resolveHostedProjectReference } from "./project-reference-resolver.ts";

const LOOKUP_INPUT = {
projectReference: "target-project",
authToken: "token-1",
apiUrl: "https://api.example.test",
};

async function withMockFetch<T>(
mockFetch: typeof globalThis.fetch,
operation: () => Promise<T>,
): Promise<T> {
const originalFetch = globalThis.fetch;
globalThis.fetch = mockFetch;
try {
return await operation();
} finally {
globalThis.fetch = originalFetch;
}
}

async function assertLookupFailure(mockFetch: typeof globalThis.fetch): Promise<void> {
await withMockFetch(mockFetch, () =>
assertRejects(
() => resolveHostedProjectReference(LOOKUP_INPUT),
Error,
"Project lookup failed (502)",
));
}

Deno.test("resolveHostedProjectReference returns a matching normalized API identity", async () => {
const requests: Array<{ url: string; authorization: string | null }> = [];

await withMockFetch(
(input, init) => {
requests.push({
url: String(input),
authorization: new Headers(init?.headers).get("authorization"),
});
return Promise.resolve(
Response.json({
id: "11111111-1111-4111-8111-111111111111",
slug: " target-project ",
}),
);
},
async () => {
assertEquals(await resolveHostedProjectReference(LOOKUP_INPUT), {
projectId: "11111111-1111-4111-8111-111111111111",
slug: "target-project",
});
},
);

assertEquals(requests, [{
url: "https://api.example.test/projects/target-project",
authorization: "Bearer token-1",
}]);
});

Deno.test("resolveHostedProjectReference rejects an API identity that does not match the request", async () => {
await withMockFetch(
() =>
Promise.resolve(
Response.json({
id: "22222222-2222-4222-8222-222222222222",
slug: "different-project",
}),
),
async () => {
await assertRejects(
() => resolveHostedProjectReference(LOOKUP_INPUT),
Error,
"Project lookup response did not confirm the requested project identity",
);
},
);
});

Deno.test("resolveHostedProjectReference ignores accessors and inherited descriptor values", async () => {
const defineProperty = Object.defineProperty;
const deleteProperty = Reflect.deleteProperty;
const previousValue = Object.getOwnPropertyDescriptor(Object.prototype, "value");
let payloadAccessorCalls = 0;
let inheritedValueCalls = 0;
const payload = defineProperty({}, "id", {
configurable: true,
enumerable: true,
get() {
payloadAccessorCalls += 1;
return "target-project";
},
});
let failure: unknown;

try {
defineProperty(Object.prototype, "value", {
configurable: true,
get() {
inheritedValueCalls += 1;
return "target-project";
},
});
await withMockFetch(
() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve(payload),
} as unknown as Response),
async () => {
try {
await resolveHostedProjectReference(LOOKUP_INPUT);
} catch (error) {
failure = error;
}
},
);
} finally {
if (previousValue) {
defineProperty(Object.prototype, "value", previousValue);
} else {
deleteProperty(Object.prototype, "value");
}
}

assertEquals(
failure instanceof Error ? failure.message : undefined,
"Project lookup response did not confirm the requested project identity",
);
assertEquals(payloadAccessorCalls, 0);
assertEquals(inheritedValueCalls, 0);
});

Deno.test("resolveHostedProjectReference rejects unsafe references before fetch", async () => {
let fetchCount = 0;

await withMockFetch(
() => {
fetchCount += 1;
throw new Error("invalid project references must not reach fetch");
},
async () => {
for (
const projectReference of [
"",
" target-project",
"target-\nproject",
"p".repeat(MAX_OPAQUE_ID_CODE_UNITS + 1),
]
) {
await assertRejects(
() => resolveHostedProjectReference({ ...LOOKUP_INPUT, projectReference }),
TypeError,
"Project reference must be a trimmed non-empty bounded identifier without control characters",
);
}
},
);

assertEquals(fetchCount, 0);
});

Deno.test("resolveHostedProjectReference preserves lookup failure when cancellation rejects", async () => {
let cancellationCount = 0;

await assertLookupFailure(() =>
Promise.resolve(
new Response(
new ReadableStream({
cancel() {
cancellationCount += 1;
return Promise.reject(new Error("cleanup failed"));
},
}),
{ status: 502 },
),
)
);
await Promise.resolve();

assertEquals(cancellationCount, 1);
});

Deno.test("resolveHostedProjectReference preserves lookup failure when cancellation throws", async () => {
let cancellationCount = 0;

await assertLookupFailure(() =>
Promise.resolve({
ok: false,
status: 502,
body: {
cancel() {
cancellationCount += 1;
throw new Error("cleanup failed");
},
},
} as unknown as Response)
);

assertEquals(cancellationCount, 1);
});

Deno.test("resolveHostedProjectReference does not await stalled error-body cancellation", async () => {
let cancellationStarted = false;
let timeoutId: ReturnType<typeof setTimeout> | undefined;

await withMockFetch(
() =>
Promise.resolve(
new Response(
new ReadableStream({
cancel() {
cancellationStarted = true;
return new Promise<void>(() => {});
},
}),
{ status: 502 },
),
),
async () => {
const timeout = new Promise<"timed-out">((resolve) => {
timeoutId = setTimeout(() => resolve("timed-out"), 100);
});

try {
const outcome = await Promise.race([
resolveHostedProjectReference(LOOKUP_INPUT).then(
() => "resolved",
(error) => error instanceof Error ? error.message : "non-error rejection",
),
timeout,
]);

assertEquals(outcome, "Project lookup failed (502)");
assertEquals(cancellationStarted, true);
} finally {
if (timeoutId !== undefined) clearTimeout(timeoutId);
}
},
);
});
Loading