Make credential requests durable and store proxy authentication
This commit is contained in:
+16
-2
@@ -35,6 +35,20 @@ For Nix, use a separate npm derivation with `importNpmLock` and the checked-in l
|
||||
Run the bundle script there. Pass its output to the Rust source derivation before Crane builds the host.
|
||||
The Rust build must not execute npm or download JavaScript packages.
|
||||
|
||||
The console reads `PI_PROXY_BASE_URL` and `PI_PROXY_API_KEY` from the host environment.
|
||||
It selects `openai/gpt-6.1-sol` with low reasoning effort.
|
||||
The console reads the `llm-proxy` credentials item through Rust.
|
||||
Its public `base_url` field contains the proxy URL. Its secret `api_key` field contains the token.
|
||||
If the item does not exist, Rust creates it from `PI_PROXY_BASE_URL` and `PI_PROXY_API_KEY` once.
|
||||
Rust encrypts the token before it commits the item. Later starts ignore these environment variables.
|
||||
The token enters JavaScript only through an operation. No startup script, transcript, or world file stores it.
|
||||
The console selects `openai/gpt-6.1-sol` with low reasoning effort.
|
||||
This model rejects disabled reasoning. Tests use the faux provider without credentials.
|
||||
|
||||
Credential requests own background pi-durable tasks in their requesting conversations.
|
||||
Filed notifications create tasks. Startup reconciliation also creates missing tasks, including requests decided during a host stop.
|
||||
A conversation document records each request id in the same commit that creates its task.
|
||||
Program requests create no task. A pending task sleeps durably and checks the decision each second.
|
||||
The harness also expires requests during these checks.
|
||||
A decided task admits one plain-language input and completes in one SQLite commit.
|
||||
The input starts a new turn when the conversation is idle. Neither the input nor the task contains a secret.
|
||||
The task and its input survive SIGKILL. The input uses a stable request id to prevent duplicate admission.
|
||||
Hosts with a shared credentials handle can use `run_module_with_credentials`.
|
||||
|
||||
+102
-42
@@ -33805,9 +33805,9 @@ async function createAgent(tx, conversation) {
|
||||
function agentHooks(agent, taskName) {
|
||||
const handlers = [];
|
||||
for (const extension of agent.extensions) {
|
||||
for (const hook of extension.hooks ?? []) {
|
||||
if (hook.task === taskName)
|
||||
handlers.push({ extension: extension.name, handlers: hook.handlers });
|
||||
for (const hook2 of extension.hooks ?? []) {
|
||||
if (hook2.task === taskName)
|
||||
handlers.push({ extension: extension.name, handlers: hook2.handlers });
|
||||
}
|
||||
}
|
||||
return handlers;
|
||||
@@ -33815,7 +33815,7 @@ function agentHooks(agent, taskName) {
|
||||
function sectionExtension(agent, key) {
|
||||
let found;
|
||||
for (const extension of agent.extensions) {
|
||||
const provides = extension.sections?.some((section) => section.key === key) === true;
|
||||
const provides = extension.sections?.some((section2) => section2.key === key) === true;
|
||||
const wraps = extension.wraps?.some((wrap) => "section" in wrap && wrap.section === key) === true;
|
||||
if (provides || wraps)
|
||||
found = extension.name;
|
||||
@@ -33840,8 +33840,8 @@ function resolveAgent(state2, snapshot, settings, report2) {
|
||||
composed.set(tool.name, tool);
|
||||
const sections = /* @__PURE__ */ new Map();
|
||||
for (const extension of extensions) {
|
||||
for (const section of extension.sections ?? [])
|
||||
sections.set(section.key, section);
|
||||
for (const section2 of extension.sections ?? [])
|
||||
sections.set(section2.key, section2);
|
||||
}
|
||||
for (const extension of extensions) {
|
||||
for (const wrap of extension.wraps ?? []) {
|
||||
@@ -33849,7 +33849,7 @@ function resolveAgent(state2, snapshot, settings, report2) {
|
||||
if ("tool" in wrap)
|
||||
applyWrap(composed, wrap.tool, (tool) => wrap.wrap(tool), (tool) => tool.name, wrapReport);
|
||||
else
|
||||
applyWrap(sections, wrap.section, (section) => wrap.wrap(section), (section) => section.key, wrapReport);
|
||||
applyWrap(sections, wrap.section, (section2) => wrap.wrap(section2), (section2) => section2.key, wrapReport);
|
||||
}
|
||||
}
|
||||
const enabled = filterTools([...composed.values()], state2?.tools);
|
||||
@@ -34685,24 +34685,24 @@ function replaySections(messages) {
|
||||
}
|
||||
async function renderSections(sections, input, shown, report2, context) {
|
||||
const desired = /* @__PURE__ */ new Map();
|
||||
for (const section of sections) {
|
||||
for (const section2 of sections) {
|
||||
let text2;
|
||||
try {
|
||||
text2 = await section.render(input, context);
|
||||
text2 = await section2.render(input, context);
|
||||
} catch (error) {
|
||||
if (context.abortSignal?.aborted)
|
||||
throw error;
|
||||
report2(error, section);
|
||||
const kept = shown.get(section.key);
|
||||
report2(error, section2);
|
||||
const kept = shown.get(section2.key);
|
||||
if (kept !== void 0)
|
||||
desired.set(section.key, kept);
|
||||
desired.set(section2.key, kept);
|
||||
continue;
|
||||
}
|
||||
if (text2 === void 0)
|
||||
continue;
|
||||
desired.set(section.key, section.tag === false ? text2 : `<${section.key}>
|
||||
desired.set(section2.key, section2.tag === false ? text2 : `<${section2.key}>
|
||||
${text2}
|
||||
</${section.key}>`);
|
||||
</${section2.key}>`);
|
||||
}
|
||||
return desired;
|
||||
}
|
||||
@@ -38706,11 +38706,11 @@ var ToolTask = defineTask({
|
||||
return reject(runtime, task.input, call, tool, invalid(checked.error), context);
|
||||
let args = checked.args;
|
||||
let block;
|
||||
await runtime.hooks.each("beforeTool", async (hook) => {
|
||||
await runtime.hooks.each("beforeTool", async (hook2) => {
|
||||
if (block !== void 0)
|
||||
return;
|
||||
try {
|
||||
const decision = await hook({ ...call, arguments: args }, runtime, context);
|
||||
const decision = await hook2({ ...call, arguments: args }, runtime, context);
|
||||
if (decision?.block !== void 0)
|
||||
block = decision.block;
|
||||
else if (decision?.arguments !== void 0)
|
||||
@@ -39052,8 +39052,8 @@ async function finalResult(runtime, input, call, tool, result, reported, context
|
||||
details: result.details === void 0 ? reported.details : result.details,
|
||||
diagnostics: [...reported.diagnostics, ...result.diagnostics ?? []]
|
||||
};
|
||||
await runtime.hooks.each("afterTool", async (hook) => {
|
||||
final = await hook(call, final, runtime, context) ?? final;
|
||||
await runtime.hooks.each("afterTool", async (hook2) => {
|
||||
final = await hook2(call, final, runtime, context) ?? final;
|
||||
});
|
||||
if (retained !== void 0 && retained.droppedBytes > 0 && final.output === output) {
|
||||
harness2.push(truncated(retained, reported.limits.retain));
|
||||
@@ -39085,8 +39085,8 @@ async function finalResult(runtime, input, call, tool, result, reported, context
|
||||
}
|
||||
async function afterHarnessResult(runtime, input, call, tool, result, truncation, context) {
|
||||
let final = result;
|
||||
await runtime.hooks.each("afterTool", async (hook) => {
|
||||
final = await hook(call, final, runtime, context) ?? final;
|
||||
await runtime.hooks.each("afterTool", async (hook2) => {
|
||||
final = await hook2(call, final, runtime, context) ?? final;
|
||||
});
|
||||
const harness2 = [];
|
||||
if (truncation !== void 0 && final.output === result.output)
|
||||
@@ -39406,9 +39406,9 @@ var GenerationTask = defineTask({
|
||||
runtime.report(error, { kind: "host", callback: "env" });
|
||||
}
|
||||
const input = { conversationId, agent, env, shown: Object.fromEntries(shown), read: runtime };
|
||||
const desired = await renderSections(agent.sections, input, shown, (error, section) => runtime.report(error, {
|
||||
const desired = await renderSections(agent.sections, input, shown, (error, section2) => runtime.report(error, {
|
||||
kind: "extension",
|
||||
...optionalExtension(sectionExtension(agent, section.key))
|
||||
...optionalExtension(sectionExtension(agent, section2.key))
|
||||
}), context);
|
||||
const entries2 = planSystemEntries(view2, desired, agent.tools, runtime.now());
|
||||
const threshold = compacted === void 0 ? thresholdCompaction(view2, entries2, resolved.contextWindow, compactionPolicy(settings, model3)) : void 0;
|
||||
@@ -39456,8 +39456,8 @@ var GenerationTask = defineTask({
|
||||
return failNoModel(runtime, ref, context);
|
||||
const view2 = await runtime.context(conversationId, context, { at: cutoff });
|
||||
let messages = view2.messages;
|
||||
await runtime.hooks.each("beforeRequest", async (hook) => {
|
||||
const replaced = await hook({ messages }, runtime, context);
|
||||
await runtime.hooks.each("beforeRequest", async (hook2) => {
|
||||
const replaced = await hook2({ messages }, runtime, context);
|
||||
if (replaced !== void 0)
|
||||
messages = replaced.messages;
|
||||
});
|
||||
@@ -39545,8 +39545,8 @@ var GenerationTask = defineTask({
|
||||
if (cutOffTooOften(task, runtime))
|
||||
return endAnsweredCutOff(runtime, task, run2.answer, context);
|
||||
const prepared = [];
|
||||
await runtime.hooks.each("afterRun", async (hook) => {
|
||||
const asked = await hook(run2, runtime, context);
|
||||
await runtime.hooks.each("afterRun", async (hook2) => {
|
||||
const asked = await hook2(run2, runtime, context);
|
||||
if (asked !== void 0)
|
||||
prepareRunEndTasks(runtime, asked.tasks, prepared);
|
||||
});
|
||||
@@ -39754,8 +39754,8 @@ async function convertPartial(tx, live, conversationId) {
|
||||
}
|
||||
async function providerPayload(runtime, model3, payload, context) {
|
||||
let current = payload;
|
||||
await runtime.hooks.each("beforeProviderRequest", async (hook) => {
|
||||
const replaced = await hook({ model: model3, payload: current }, runtime, context);
|
||||
await runtime.hooks.each("beforeProviderRequest", async (hook2) => {
|
||||
const replaced = await hook2({ model: model3, payload: current }, runtime, context);
|
||||
if (replaced !== void 0)
|
||||
current = replaced.payload;
|
||||
});
|
||||
@@ -39839,7 +39839,7 @@ async function classify2(runtime, request2, message, context) {
|
||||
}, context);
|
||||
return;
|
||||
}
|
||||
await runtime.hooks.each("afterResponse", (hook) => hook(message, runtime, context));
|
||||
await runtime.hooks.each("afterResponse", (hook2) => hook2(message, runtime, context));
|
||||
const calls = message.content.filter((content) => content.type === "toolCall");
|
||||
if (message.stopReason === "toolUse" && calls.length > 0) {
|
||||
return startToolRound(runtime, request2, message, calls, context);
|
||||
@@ -39892,10 +39892,10 @@ async function classify2(runtime, request2, message, context) {
|
||||
}
|
||||
async function answer(runtime, message, context) {
|
||||
let continuation;
|
||||
await runtime.hooks.each("onYield", async (hook) => {
|
||||
await runtime.hooks.each("onYield", async (hook2) => {
|
||||
if (continuation !== void 0)
|
||||
return;
|
||||
continuation = (await hook(message, runtime, context))?.continue;
|
||||
continuation = (await hook2(message, runtime, context))?.continue;
|
||||
});
|
||||
const afterRun = await asksAfterRun(runtime, context);
|
||||
const conversationId = runtime.conversationId;
|
||||
@@ -40038,7 +40038,7 @@ async function finishToolRound(runtime, assistant, tools, context) {
|
||||
});
|
||||
const slots = (await runtime.snapshot(LiveDoc, conversationId, context))?.tools ?? [];
|
||||
const results = slots.flatMap((slot) => slot.entry === void 0 ? [] : [slot.entry]);
|
||||
await runtime.hooks.each("afterTools", (hook) => hook(assistant, results, runtime, context));
|
||||
await runtime.hooks.each("afterTools", (hook2) => hook2(assistant, results, runtime, context));
|
||||
const ordered = slots.map((slot) => slot.taskId === void 0 ? void 0 : controls.get(slot.taskId));
|
||||
const terminate = slots.length > 0 && ordered.every((control) => control?.terminate === true);
|
||||
const added = ordered.flatMap((control) => control?.addTools ?? []);
|
||||
@@ -40583,9 +40583,9 @@ var CompactionTask = defineTask({
|
||||
...instructions === void 0 ? {} : { instructions }
|
||||
};
|
||||
let decision;
|
||||
await runtime.hooks.each("beforeCompact", async (hook) => {
|
||||
await runtime.hooks.each("beforeCompact", async (hook2) => {
|
||||
if (decision === void 0)
|
||||
decision = await hook(compaction, runtime, context);
|
||||
decision = await hook2(compaction, runtime, context);
|
||||
});
|
||||
if (decision !== void 0 && "decline" in decision)
|
||||
return complete(runtime, context);
|
||||
@@ -40919,7 +40919,7 @@ var RegistryState = class {
|
||||
return this.#extensions.flatMap((extension) => (extension.tools ?? []).map((tool) => ({ extension, tool })));
|
||||
}
|
||||
sections() {
|
||||
return this.#extensions.flatMap((extension) => (extension.sections ?? []).map((section) => ({ extension, section })));
|
||||
return this.#extensions.flatMap((extension) => (extension.sections ?? []).map((section2) => ({ extension, section: section2 })));
|
||||
}
|
||||
tasks() {
|
||||
return [...this.#tasks.values()];
|
||||
@@ -44424,10 +44424,67 @@ function environment(cwd = "/", conversation) {
|
||||
return env;
|
||||
}
|
||||
|
||||
// main.ts
|
||||
if (!process.env.PI_PROXY_BASE_URL || !process.env.PI_PROXY_API_KEY) {
|
||||
throw new Error("PI_PROXY_BASE_URL and PI_PROXY_API_KEY are required");
|
||||
// credentials.ts
|
||||
var Requests = defineDoc({
|
||||
kind: "sausage.credential.requests",
|
||||
version: 1,
|
||||
scope: "conversation",
|
||||
history: "latest",
|
||||
fork: "initial",
|
||||
initial: () => ({ ids: [] }),
|
||||
checkpointWhen: () => false
|
||||
});
|
||||
var Wait = defineTask({
|
||||
name: "sausage.credential.wait",
|
||||
version: 1,
|
||||
initial: () => ({ phase: "wait" }),
|
||||
phases: {
|
||||
wait: async (task, runtime, context) => {
|
||||
const request2 = Deno.core.ops.op_credential_requests().find((r) => r.id === task.input.id);
|
||||
if (!request2 || request2.state === "pending") {
|
||||
await runtime.sleepUntil(Date.now() + 1e3, { phase: "wait" }, context);
|
||||
return;
|
||||
}
|
||||
const conversation = await runtime.conversation(runtime.conversationId, context);
|
||||
await runtime.commit(async (tx) => {
|
||||
await conversation.submit(tx, { type: "input", content: request2.message, requestId: `credential:${request2.id}` });
|
||||
return { status: "terminal", outcome: { status: "completed", result: null } };
|
||||
}, context);
|
||||
}
|
||||
},
|
||||
abort: (_task, runtime, context) => runtime.commit(() => ({ status: "terminal", outcome: { status: "aborted" } }), context)
|
||||
});
|
||||
var CredentialRequests = defineExtension({ name: "sausage.credentials", tasks: [Wait] });
|
||||
async function discoverRequests(harness2, context) {
|
||||
for (const request2 of Deno.core.ops.op_credential_requests()) {
|
||||
const conversation = await harness2.conversation(Number(request2.conversation), context);
|
||||
if (!conversation) continue;
|
||||
await conversation.commit(async (tx) => {
|
||||
const recorded = await tx.doc(Requests, conversation.id);
|
||||
if (recorded.ids.includes(request2.id)) return;
|
||||
await tx.createTask(Wait, { id: request2.id }, { ownership: { kind: "conversation" }, background: true });
|
||||
recorded.ids.push(request2.id);
|
||||
}, context);
|
||||
}
|
||||
}
|
||||
async function startRequests(harness2, context) {
|
||||
await discoverRequests(harness2, context);
|
||||
let active = true;
|
||||
const pending = (async () => {
|
||||
while (active) {
|
||||
await Deno.core.ops.op_credential_next();
|
||||
if (active) await discoverRequests(harness2, context);
|
||||
}
|
||||
})();
|
||||
return async () => {
|
||||
active = false;
|
||||
await pending;
|
||||
};
|
||||
}
|
||||
|
||||
// main.ts
|
||||
var proxy = Deno.core.ops.op_model_proxy();
|
||||
if (!proxy?.base_url || !proxy?.api_key) throw new Error("The llm-proxy credential is required");
|
||||
var models = createModels();
|
||||
var provider2 = openaiProvider();
|
||||
var model2 = {
|
||||
@@ -44435,7 +44492,7 @@ var model2 = {
|
||||
name: "gpt-6.1-sol",
|
||||
provider: "openai",
|
||||
api: "openai-responses",
|
||||
baseUrl: `${process.env.PI_PROXY_BASE_URL}/v1`,
|
||||
baseUrl: `${proxy.base_url}/v1`,
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
thinkingLevelMap: { off: null, minimal: null },
|
||||
@@ -44449,21 +44506,22 @@ models.setProvider({
|
||||
getModels: () => [model2],
|
||||
auth: { apiKey: { name: "Sausage proxy", resolve: async () => ({
|
||||
auth: {
|
||||
apiKey: process.env.PI_PROXY_API_KEY,
|
||||
apiKey: proxy.api_key,
|
||||
baseUrl: model2.baseUrl,
|
||||
headers: { "Shopify-Usage-Tag": JSON.stringify(["pi", "sausage"]) }
|
||||
},
|
||||
source: "PI_PROXY_API_KEY"
|
||||
source: "llm-proxy"
|
||||
}) } }
|
||||
});
|
||||
var registry = createRegistry();
|
||||
registry.install(CodingTools);
|
||||
registry.install(CredentialRequests);
|
||||
var harness = await Harness.open(await openStorage(globalThis.storagePath), {
|
||||
models,
|
||||
registry,
|
||||
env: (target) => environment(target.cwd ?? "/", target.conversationId)
|
||||
}, BACKGROUND_CONTEXT);
|
||||
var root = await harness.root(BACKGROUND_CONTEXT, { agent: { model: { provider: "openai", modelId: model2.id }, thinkingLevel: "low", cwd: "/", extensions: [CodingTools] } });
|
||||
var root = await harness.root(BACKGROUND_CONTEXT, { agent: { model: { provider: "openai", modelId: model2.id }, thinkingLevel: "low", cwd: "/", extensions: [CodingTools, CredentialRequests] } });
|
||||
var watch = await root.watch(BACKGROUND_CONTEXT);
|
||||
var emitted = /* @__PURE__ */ new Map();
|
||||
var partial = "";
|
||||
@@ -44488,6 +44546,7 @@ for (const entry2 of watch.value.entries) {
|
||||
watch.start((event) => {
|
||||
if (event.kind === "value") render(event.value);
|
||||
});
|
||||
var stopRequests = await startRequests(harness, BACKGROUND_CONTEXT);
|
||||
harness.resume();
|
||||
await harness.waitForIdle(BACKGROUND_CONTEXT);
|
||||
while (true) {
|
||||
@@ -44503,5 +44562,6 @@ while (true) {
|
||||
Deno.core.ops.op_output("\n");
|
||||
if (result.status !== "done") throw new Error(`Run ended: ${JSON.stringify(result)}`);
|
||||
}
|
||||
await stopRequests();
|
||||
await watch.stop();
|
||||
await harness.close(BACKGROUND_CONTEXT);
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
// Durable, background credential waits. Admission and completion share one SQLite commit.
|
||||
import { defineDoc, defineTask, defineExtension } from '@earendil-works/pi-durable';
|
||||
const Requests = defineDoc<{ ids: string[] }>({
|
||||
kind: 'sausage.credential.requests', version: 1, scope: 'conversation',
|
||||
history: 'latest', fork: 'initial', initial: () => ({ ids: [] }), checkpointWhen: () => false,
|
||||
});
|
||||
const Wait = defineTask<{ id: string }, { phase: 'wait' }, null>({
|
||||
name: 'sausage.credential.wait', version: 1, initial: () => ({ phase: 'wait' }),
|
||||
phases: {
|
||||
wait: async (task, runtime, context) => {
|
||||
const request = Deno.core.ops.op_credential_requests().find((r: any) => r.id === task.input.id);
|
||||
if (!request || request.state === 'pending') {
|
||||
await runtime.sleepUntil(Date.now() + 1000, { phase: 'wait' }, context);
|
||||
return;
|
||||
}
|
||||
const conversation = (await runtime.conversation(runtime.conversationId, context))!;
|
||||
await runtime.commit(async (tx) => {
|
||||
await conversation.submit(tx, { type: 'input', content: request.message, requestId: `credential:${request.id}` });
|
||||
return { status: 'terminal', outcome: { status: 'completed', result: null } };
|
||||
}, context);
|
||||
},
|
||||
},
|
||||
abort: (_task, runtime, context) => runtime.commit(() => ({ status: 'terminal', outcome: { status: 'aborted' } }), context),
|
||||
});
|
||||
export const CredentialRequests = defineExtension({ name: 'sausage.credentials', tasks: [Wait] });
|
||||
export async function discoverRequests(harness: any, context: any) {
|
||||
for (const request of Deno.core.ops.op_credential_requests()) {
|
||||
const conversation = await harness.conversation(Number(request.conversation), context);
|
||||
if (!conversation) continue;
|
||||
await conversation.commit(async (tx: any) => {
|
||||
const recorded = await tx.doc(Requests, conversation.id);
|
||||
if (recorded.ids.includes(request.id)) return;
|
||||
await tx.createTask(Wait, { id: request.id }, { ownership: { kind: 'conversation' }, background: true });
|
||||
recorded.ids.push(request.id);
|
||||
}, context);
|
||||
}
|
||||
}
|
||||
export async function startRequests(harness: any, context: any) {
|
||||
await discoverRequests(harness, context);
|
||||
// Filed notifications start waits. The deadline reconciles other handles and lost notifications.
|
||||
let active = true;
|
||||
const pending = (async () => {
|
||||
while (active) {
|
||||
await Deno.core.ops.op_credential_next();
|
||||
if (active) await discoverRequests(harness, context);
|
||||
}
|
||||
})();
|
||||
return async () => { active = false; await pending; };
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,47 @@
|
||||
// Faux-model acceptance probe for credential wake-ups, including host restart.
|
||||
import { BACKGROUND_CONTEXT as context } from '@earendil-works/chord/context';
|
||||
import { createModels } from '@earendil-works/pi-ai/models';
|
||||
import { fauxProvider, fauxAssistantMessage, fauxText } from '@earendil-works/pi-ai/providers/faux';
|
||||
import { Harness, createRegistry } from '@earendil-works/pi-durable';
|
||||
import { openStorage } from './sqlite';
|
||||
import { CredentialRequests, startRequests } from './credentials';
|
||||
const models = createModels();
|
||||
const faux = fauxProvider({ tokensPerSecond: 100000 });
|
||||
models.setProvider(faux.provider);
|
||||
faux.setResponses(Array.from({ length: 8 }, () => fauxAssistantMessage([fauxText('done')])));
|
||||
const registry = createRegistry();
|
||||
registry.install(CredentialRequests);
|
||||
const harness = await Harness.open(await openStorage(globalThis.storagePath), { models, registry }, context);
|
||||
const root = await harness.root(context, { agent: { model: { provider: 'faux', modelId: 'faux-1' }, extensions: [CredentialRequests] } });
|
||||
await (await root.submit({ type: 'input', content: 'initial', requestId: 'initial' }, context)).wait(context);
|
||||
if (globalThis.proxyOnly) {
|
||||
await harness.close(context);
|
||||
} else {
|
||||
await Deno.core.ops.op_file('write', '/conversation', Array.from(new TextEncoder().encode(root.id)));
|
||||
const stop = await startRequests(harness, context);
|
||||
harness.resume();
|
||||
const watch = await root.watch(context);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const check = (event: any) => {
|
||||
if (event.kind !== 'value') return;
|
||||
const messages = event.value.entries.filter((e: any) => JSON.stringify(e).includes('Your request for'));
|
||||
if (messages.length > 1) reject(new Error('Duplicate credential message'));
|
||||
if (messages.length === 1) {
|
||||
const message = JSON.stringify(messages);
|
||||
if (!message.includes('`github`') || !message.includes(globalThis.expectedState)) reject(new Error('Wrong credential decision message'));
|
||||
else resolve();
|
||||
}
|
||||
};
|
||||
watch.start(check);
|
||||
check({ kind: 'value', value: watch.value });
|
||||
});
|
||||
await harness.waitForIdle(context);
|
||||
const final = await root.watch(context);
|
||||
if (final.value.entries.filter((entry: any) => entry.kind === 'pi.assistant').length !== 2) {
|
||||
throw new Error('The decision must start exactly one model turn');
|
||||
}
|
||||
await final.stop();
|
||||
await stop();
|
||||
await watch.stop();
|
||||
await harness.close(context);
|
||||
}
|
||||
+10
-7
@@ -6,30 +6,31 @@ import { Harness, createRegistry } from '@earendil-works/pi-durable';
|
||||
import { CodingTools } from '@earendil-works/pi-durable/tools';
|
||||
import { openStorage } from './sqlite';
|
||||
import { environment } from './environment';
|
||||
if (!process.env.PI_PROXY_BASE_URL || !process.env.PI_PROXY_API_KEY) {
|
||||
throw new Error('PI_PROXY_BASE_URL and PI_PROXY_API_KEY are required');
|
||||
}
|
||||
import { CredentialRequests, startRequests } from './credentials';
|
||||
const proxy = Deno.core.ops.op_model_proxy();
|
||||
if (!proxy?.base_url || !proxy?.api_key) throw new Error('The llm-proxy credential is required');
|
||||
const models = createModels();
|
||||
const provider = openaiProvider();
|
||||
const model = {
|
||||
id: 'gpt-6.1-sol', name: 'gpt-6.1-sol', provider: 'openai', api: 'openai-responses',
|
||||
baseUrl: `${process.env.PI_PROXY_BASE_URL}/v1`, reasoning: true, input: ['text'],
|
||||
baseUrl: `${proxy.base_url}/v1`, reasoning: true, input: ['text'],
|
||||
thinkingLevelMap: { off: null, minimal: null },
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 200000, maxTokens: 4096,
|
||||
};
|
||||
models.setProvider({ ...provider, baseUrl: model.baseUrl, getModels: () => [model],
|
||||
auth: { apiKey: { name: 'Sausage proxy', resolve: async () => ({
|
||||
auth: { apiKey: process.env.PI_PROXY_API_KEY, baseUrl: model.baseUrl,
|
||||
auth: { apiKey: proxy.api_key, baseUrl: model.baseUrl,
|
||||
headers: { 'Shopify-Usage-Tag': JSON.stringify(['pi', 'sausage']) } },
|
||||
source: 'PI_PROXY_API_KEY',
|
||||
source: 'llm-proxy',
|
||||
}) } },
|
||||
});
|
||||
const registry = createRegistry();
|
||||
registry.install(CodingTools);
|
||||
registry.install(CredentialRequests);
|
||||
const harness = await Harness.open(await openStorage(globalThis.storagePath), {
|
||||
models, registry, env: (target: any) => environment(target.cwd ?? '/', target.conversationId),
|
||||
}, context);
|
||||
const root = await harness.root(context, { agent: { model: { provider: 'openai', modelId: model.id }, thinkingLevel: 'low', cwd: '/', extensions: [CodingTools] } });
|
||||
const root = await harness.root(context, { agent: { model: { provider: 'openai', modelId: model.id }, thinkingLevel: 'low', cwd: '/', extensions: [CodingTools, CredentialRequests] } });
|
||||
const watch = await root.watch(context);
|
||||
const emitted = new Map<number, string>();
|
||||
let partial = '';
|
||||
@@ -53,6 +54,7 @@ for (const entry of watch.value.entries) {
|
||||
if (entry.kind === 'pi.assistant') emitted.set(entry.id, text(entry.model?.[0]));
|
||||
}
|
||||
watch.start((event: any) => { if (event.kind === 'value') render(event.value); });
|
||||
const stopRequests = await startRequests(harness, context);
|
||||
harness.resume();
|
||||
await harness.waitForIdle(context);
|
||||
while (true) {
|
||||
@@ -68,5 +70,6 @@ while (true) {
|
||||
Deno.core.ops.op_output('\n');
|
||||
if (result.status !== 'done') throw new Error(`Run ended: ${JSON.stringify(result)}`);
|
||||
}
|
||||
await stopRequests();
|
||||
await watch.stop();
|
||||
await harness.close(context);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": { "bundle": "esbuild main.ts --bundle --format=esm --platform=browser --outfile=bundle.js && esbuild test.ts --bundle --format=esm --platform=browser --outfile=test-bundle.js" },
|
||||
"scripts": { "bundle": "esbuild main.ts --bundle --format=esm --platform=browser --outfile=bundle.js && esbuild test.ts --bundle --format=esm --platform=browser --outfile=test-bundle.js && esbuild durable-test.ts --bundle --format=esm --platform=browser --outfile=durable-test-bundle.js" },
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-durable": "1.5.0",
|
||||
"@earendil-works/pi-ai": "1.5.0",
|
||||
|
||||
@@ -182,6 +182,7 @@ impl Cred {
|
||||
if let Some(expired) = expired_id {
|
||||
let _ = self.decided.send(expired);
|
||||
}
|
||||
let _ = self.filed.send(id.clone());
|
||||
Ok(id)
|
||||
}
|
||||
pub fn request_list(&self) -> Result<Vec<Request>> {
|
||||
|
||||
@@ -16,6 +16,7 @@ pub(crate) struct Store {
|
||||
pub struct Cred {
|
||||
pub(crate) store: Mutex<Store>,
|
||||
pub(crate) decided: tokio::sync::broadcast::Sender<String>,
|
||||
pub(crate) filed: tokio::sync::broadcast::Sender<String>,
|
||||
}
|
||||
impl Cred {
|
||||
pub fn open(host_dir: &Path) -> Result<Self> {
|
||||
@@ -50,9 +51,11 @@ impl Cred {
|
||||
db.busy_timeout(std::time::Duration::from_secs(5))?;
|
||||
db.execute_batch(schema::DDL)?;
|
||||
let (decided, _) = tokio::sync::broadcast::channel(64);
|
||||
let (filed, _) = tokio::sync::broadcast::channel(64);
|
||||
let cred = Self {
|
||||
store: Mutex::new(Store { db, master }),
|
||||
decided,
|
||||
filed,
|
||||
};
|
||||
cred.expire_requests()?;
|
||||
Ok(cred)
|
||||
@@ -60,6 +63,9 @@ impl Cred {
|
||||
pub(crate) fn access(&self) -> Result<MutexGuard<'_, Store>> {
|
||||
self.store.lock().map_err(|_| Error("storage"))
|
||||
}
|
||||
pub fn subscribe_filed(&self) -> tokio::sync::broadcast::Receiver<String> {
|
||||
self.filed.subscribe()
|
||||
}
|
||||
pub fn subscribe(&self) -> tokio::sync::broadcast::Receiver<String> {
|
||||
self.decided.subscribe()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
//! Credential request snapshots and host-only model proxy configuration.
|
||||
use crate::cred::{store::Cred, types::Subject};
|
||||
use deno_core::{OpState, op2};
|
||||
use deno_error::JsErrorBox;
|
||||
use serde_json::{Value, json};
|
||||
use std::{cell::RefCell, rc::Rc, sync::Arc};
|
||||
|
||||
pub(super) struct Notifications {
|
||||
filed: tokio::sync::Mutex<tokio::sync::broadcast::Receiver<String>>,
|
||||
}
|
||||
impl Notifications {
|
||||
pub(super) fn new(cred: &Cred) -> Rc<Self> {
|
||||
Rc::new(Self {
|
||||
filed: tokio::sync::Mutex::new(cred.subscribe_filed()),
|
||||
})
|
||||
}
|
||||
}
|
||||
#[op2]
|
||||
pub(super) async fn op_credential_next(state: Rc<RefCell<OpState>>) {
|
||||
let notifications = state.borrow().borrow::<Rc<Notifications>>().clone();
|
||||
let mut filed = notifications.filed.lock().await;
|
||||
let _ = tokio::time::timeout(std::time::Duration::from_secs(1), filed.recv()).await;
|
||||
}
|
||||
|
||||
#[op2]
|
||||
#[serde]
|
||||
pub(super) fn op_credential_requests(state: &mut OpState) -> Result<serde_json::Value, JsErrorBox> {
|
||||
let cred = state.borrow::<Arc<Cred>>();
|
||||
let work = || -> anyhow::Result<Value> {
|
||||
cred.expire_requests()?;
|
||||
// TODO(T27): prune retained records once a day after the library adds Cred::prune.
|
||||
let mut rows = Vec::new();
|
||||
for request in cred.request_list()? {
|
||||
let Subject::Conversation(conversation) = request.subject else {
|
||||
continue;
|
||||
};
|
||||
let names = request
|
||||
.payload
|
||||
.get("items")
|
||||
.and_then(Value::as_array)
|
||||
.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.map(|id| {
|
||||
cred.item_get(id)
|
||||
.map(|item| format!("`{}`", item.name))
|
||||
.unwrap_or_else(|_| "the credential".into())
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
})
|
||||
.unwrap_or_else(|| "the credential change".into());
|
||||
let message = match request.state.as_str() {
|
||||
"approved" => format!("Your request for {names} was approved. Retry the command."),
|
||||
"denied" => format!("Your request for {names} was denied."),
|
||||
"expired" => format!(
|
||||
"Your request for {names} expired. File a new request if you still need it."
|
||||
),
|
||||
_ => String::new(),
|
||||
};
|
||||
rows.push(json!({"id":request.id,"conversation":conversation,"state":request.state,"message":message}));
|
||||
}
|
||||
Ok(json!(rows))
|
||||
};
|
||||
work().map_err(|_| JsErrorBox::generic("Cannot read credential requests"))
|
||||
}
|
||||
|
||||
pub(super) fn proxy(cred: &Cred) -> anyhow::Result<Option<Value>> {
|
||||
use crate::cred::{
|
||||
items::{Field, Item},
|
||||
types::{Caller, FieldType},
|
||||
};
|
||||
let caller = Caller {
|
||||
pid: 0,
|
||||
program: "host".into(),
|
||||
conversations: vec![],
|
||||
};
|
||||
let item = cred
|
||||
.item_list()?
|
||||
.into_iter()
|
||||
.find(|item| item.name == "llm-proxy");
|
||||
let item = match item {
|
||||
Some(item) => item,
|
||||
None => {
|
||||
let (Ok(base), Ok(key)) = (
|
||||
std::env::var("PI_PROXY_BASE_URL"),
|
||||
std::env::var("PI_PROXY_API_KEY"),
|
||||
) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let mut store = cred.access()?;
|
||||
let master = store.master.clone();
|
||||
let tx = store
|
||||
.db
|
||||
.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
|
||||
let mut item = crate::cred::items::insert_new(
|
||||
&tx,
|
||||
Item {
|
||||
id: String::new(),
|
||||
name: "llm-proxy".into(),
|
||||
version: 0,
|
||||
free_use: false,
|
||||
rules: vec![],
|
||||
validate_js: None,
|
||||
transform_js: "export default (ctx) => ctx.request".into(),
|
||||
fields: vec![
|
||||
Field {
|
||||
field_id: String::new(),
|
||||
name: "base_url".into(),
|
||||
public: true,
|
||||
value: Some(base),
|
||||
needs_value: false,
|
||||
field_type: FieldType::Text,
|
||||
key_type: None,
|
||||
},
|
||||
Field {
|
||||
field_id: String::new(),
|
||||
name: "api_key".into(),
|
||||
public: false,
|
||||
value: None,
|
||||
needs_value: true,
|
||||
field_type: FieldType::Text,
|
||||
key_type: None,
|
||||
},
|
||||
],
|
||||
},
|
||||
&caller,
|
||||
Some("host bootstrap"),
|
||||
)?;
|
||||
let field = &item
|
||||
.fields
|
||||
.iter()
|
||||
.find(|f| f.name == "api_key")
|
||||
.unwrap()
|
||||
.field_id;
|
||||
let sealed =
|
||||
crate::cred::seal::seal(&master, &format!("{}|{field}", item.id), key.as_bytes())?;
|
||||
tx.execute(
|
||||
"INSERT INTO fields VALUES(?1,?2,?3)",
|
||||
rusqlite::params![item.id, field, sealed],
|
||||
)?;
|
||||
item.fields
|
||||
.iter_mut()
|
||||
.find(|f| f.name == "api_key")
|
||||
.unwrap()
|
||||
.needs_value = false;
|
||||
crate::cred::items::save(&tx, &item)?;
|
||||
tx.commit()?;
|
||||
item
|
||||
}
|
||||
};
|
||||
Ok(Some(cred.credential(&item, true)?))
|
||||
}
|
||||
|
||||
#[op2]
|
||||
#[serde]
|
||||
pub(super) fn op_model_proxy(state: &mut OpState) -> Result<serde_json::Value, JsErrorBox> {
|
||||
proxy(state.borrow::<Arc<Cred>>())
|
||||
.map(|v| v.unwrap_or(Value::Null))
|
||||
.map_err(|_| JsErrorBox::generic("Cannot read llm-proxy credential"))
|
||||
}
|
||||
+41
-10
@@ -1,4 +1,5 @@
|
||||
//! Durable V8 harness, host-side SQLite storage, and world tool operations.
|
||||
mod credentials;
|
||||
mod environment;
|
||||
use crate::{exec::Exec, world::World};
|
||||
use deno_core::{JsRuntime, OpState, RuntimeOptions, op2};
|
||||
@@ -78,6 +79,9 @@ deno_core::extension!(
|
||||
storage,
|
||||
ops = [
|
||||
op_sql,
|
||||
credentials::op_credential_requests,
|
||||
credentials::op_model_proxy,
|
||||
credentials::op_credential_next,
|
||||
environment::op_file,
|
||||
environment::op_exec,
|
||||
environment::op_exec_next,
|
||||
@@ -112,6 +116,18 @@ pub async fn run_with_exec(world: World, exec: Arc<dyn Exec>) -> anyhow::Result<
|
||||
/// Evaluate a bundled harness module with the same storage and tool operations as the console.
|
||||
/// The caller must use a current-thread Tokio runtime.
|
||||
pub async fn run_module(world: World, exec: Arc<dyn Exec>, source: &str) -> anyhow::Result<()> {
|
||||
let cred = Arc::new(crate::cred::store::Cred::open(&world.host_dir)?);
|
||||
run_module_with_credentials(world, exec, cred, source).await
|
||||
}
|
||||
|
||||
/// Run a harness with the credentials handle shared by the host API.
|
||||
pub async fn run_module_with_credentials(
|
||||
world: World,
|
||||
exec: Arc<dyn Exec>,
|
||||
cred: Arc<crate::cred::store::Cred>,
|
||||
source: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
let proxy = credentials::proxy(&cred)?;
|
||||
let mut runtime = JsRuntime::new(RuntimeOptions {
|
||||
extensions: vec![
|
||||
deno_telemetry::deno_telemetry::init(),
|
||||
@@ -162,11 +178,11 @@ pub async fn run_module(world: World, exec: Arc<dyn Exec>, source: &str) -> anyh
|
||||
deno_permissions::RuntimePermissionDescriptorParser::new(sys_traits::impls::RealSys),
|
||||
)));
|
||||
runtime.execute_script("bootstrap.js", include_str!("../../js/bootstrap.js"))?;
|
||||
let env: serde_json::Map<String, Value> = ["PI_PROXY_BASE_URL", "PI_PROXY_API_KEY"]
|
||||
.into_iter()
|
||||
.filter_map(|key| std::env::var(key).ok().map(|v| (key.to_owned(), json!(v))))
|
||||
.collect();
|
||||
runtime.execute_script("env.js", format!("process.env = {};", Value::Object(env)))?;
|
||||
runtime
|
||||
.op_state()
|
||||
.borrow_mut()
|
||||
.put(credentials::Notifications::new(&cred));
|
||||
runtime.op_state().borrow_mut().put(cred);
|
||||
runtime
|
||||
.op_state()
|
||||
.borrow_mut()
|
||||
@@ -186,11 +202,26 @@ pub async fn run_module(world: World, exec: Arc<dyn Exec>, source: &str) -> anyh
|
||||
.load_main_es_module_from_code(&specifier, source.to_owned())
|
||||
.await?;
|
||||
let result = runtime.mod_evaluate(module);
|
||||
runtime
|
||||
.with_event_loop_promise(result, Default::default())
|
||||
.await?;
|
||||
runtime.run_event_loop(Default::default()).await?;
|
||||
Ok(())
|
||||
let result = async {
|
||||
runtime
|
||||
.with_event_loop_promise(result, Default::default())
|
||||
.await?;
|
||||
runtime.run_event_loop(Default::default()).await?;
|
||||
Ok::<_, anyhow::Error>(())
|
||||
}
|
||||
.await;
|
||||
result.map_err(|error| {
|
||||
let mut text = error.to_string();
|
||||
if let Some(key) = proxy
|
||||
.as_ref()
|
||||
.and_then(|v| v.get("api_key"))
|
||||
.and_then(Value::as_str)
|
||||
&& !key.is_empty()
|
||||
{
|
||||
text = text.replace(key, "[redacted]");
|
||||
}
|
||||
anyhow::anyhow!(text)
|
||||
})
|
||||
}
|
||||
|
||||
/// Open a world and resume its durable root conversation.
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
//! Credential decisions wake an idle faux-model conversation through durable tasks.
|
||||
use sausage::{
|
||||
cred::{
|
||||
items::Item,
|
||||
requests::{GrantRequest, Overrides},
|
||||
store::Cred,
|
||||
types::Caller,
|
||||
},
|
||||
harness::{run_module, run_module_with_credentials},
|
||||
world::World,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn restart_child() -> anyhow::Result<()> {
|
||||
let Ok(dir) = std::env::var("DURABLE_RESTART_DIR") else {
|
||||
return Ok(());
|
||||
};
|
||||
let dir = std::path::Path::new(&dir);
|
||||
let source = format!(
|
||||
"globalThis.expectedState = 'approved';\n{}",
|
||||
include_str!("../js/durable-test-bundle.js")
|
||||
);
|
||||
run_module(
|
||||
World::open(dir)?,
|
||||
sausage::process::exec(World::open(dir)?)?,
|
||||
&source,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sigkill_preserves_request_wait() -> anyhow::Result<()> {
|
||||
use std::{
|
||||
process::Command,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
let dir = std::env::temp_dir().join(format!("sausage-durable-restart-{}", std::process::id()));
|
||||
let world = World::open(&dir)?;
|
||||
let cred = Cred::open(&world.host_dir)?;
|
||||
let admin = Caller {
|
||||
pid: 0,
|
||||
program: "test".into(),
|
||||
conversations: vec![],
|
||||
};
|
||||
let item = cred.create_item(
|
||||
Item {
|
||||
id: String::new(),
|
||||
name: "github".into(),
|
||||
version: 0,
|
||||
free_use: false,
|
||||
rules: vec![],
|
||||
validate_js: None,
|
||||
transform_js: "export default (ctx) => ctx.request".into(),
|
||||
fields: vec![],
|
||||
},
|
||||
&admin,
|
||||
"test",
|
||||
)?;
|
||||
let mut child = Command::new(std::env::current_exe()?)
|
||||
.args(["--exact", "restart_child", "--nocapture"])
|
||||
.env("DURABLE_RESTART_DIR", &dir)
|
||||
.spawn()?;
|
||||
let deadline = Instant::now() + Duration::from_secs(30);
|
||||
let conversation = loop {
|
||||
if let Ok(id) = std::fs::read_to_string(dir.join("world/conversation"))
|
||||
&& !id.is_empty()
|
||||
{
|
||||
break id;
|
||||
}
|
||||
if Instant::now() > deadline {
|
||||
child.kill()?;
|
||||
child.wait()?;
|
||||
anyhow::bail!("Conversation did not start");
|
||||
}
|
||||
std::thread::yield_now();
|
||||
};
|
||||
let caller = Caller {
|
||||
conversations: vec![conversation],
|
||||
..admin
|
||||
};
|
||||
let id = cred.file_request(
|
||||
&caller,
|
||||
"restart",
|
||||
GrantRequest {
|
||||
items: vec![item.id],
|
||||
duration_secs: None,
|
||||
max_uses: None,
|
||||
validator: None,
|
||||
},
|
||||
)?;
|
||||
let db = rusqlite::Connection::open(dir.join("host/harness.sqlite"))?;
|
||||
loop {
|
||||
let count: i64 = db.query_row(
|
||||
"SELECT count(*) FROM tasks WHERE json_extract(kind, '$')='sausage.credential.wait'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
if count == 1 {
|
||||
break;
|
||||
}
|
||||
if Instant::now() > deadline {
|
||||
child.kill()?;
|
||||
child.wait()?;
|
||||
anyhow::bail!("Durable wait did not start");
|
||||
}
|
||||
std::thread::yield_now();
|
||||
}
|
||||
child.kill()?;
|
||||
child.wait()?;
|
||||
cred.approve(&id, "yes", Overrides::default(), Some("test"))?;
|
||||
for _ in 0..2 {
|
||||
let mut resumed = Command::new(std::env::current_exe()?)
|
||||
.args(["--exact", "restart_child", "--nocapture"])
|
||||
.env("DURABLE_RESTART_DIR", &dir)
|
||||
.spawn()?;
|
||||
let deadline = Instant::now() + Duration::from_secs(30);
|
||||
loop {
|
||||
if let Some(status) = resumed.try_wait()? {
|
||||
assert!(status.success());
|
||||
break;
|
||||
}
|
||||
if Instant::now() > deadline {
|
||||
resumed.kill()?;
|
||||
resumed.wait()?;
|
||||
anyhow::bail!("Restart did not finish");
|
||||
}
|
||||
std::thread::yield_now();
|
||||
}
|
||||
}
|
||||
drop(db);
|
||||
std::fs::remove_dir_all(dir)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn proxy_child() -> anyhow::Result<()> {
|
||||
let Ok(dir) = std::env::var("DURABLE_PROXY_DIR") else {
|
||||
return Ok(());
|
||||
};
|
||||
let dir = std::path::Path::new(&dir);
|
||||
let source = format!(
|
||||
"globalThis.proxyOnly = true; const proxy = Deno.core.ops.op_model_proxy(); if (proxy.base_url !== 'https://proxy.invalid' || proxy.api_key !== 'faux-private-token') throw new Error('Wrong proxy item');\n{}",
|
||||
include_str!("../js/durable-test-bundle.js")
|
||||
);
|
||||
run_module(
|
||||
World::open(dir)?,
|
||||
sausage::process::exec(World::open(dir)?)?,
|
||||
&source,
|
||||
)
|
||||
.await?;
|
||||
let error = run_module(
|
||||
World::open(dir)?,
|
||||
sausage::process::exec(World::open(dir)?)?,
|
||||
"throw new Error(Deno.core.ops.op_model_proxy().api_key)",
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(!error.to_string().contains("faux-private-token"));
|
||||
assert!(error.to_string().contains("[redacted]"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proxy_bootstrap_once() -> anyhow::Result<()> {
|
||||
let dir = std::env::temp_dir().join(format!("sausage-durable-proxy-{}", std::process::id()));
|
||||
for start in 0..2 {
|
||||
let status = std::process::Command::new(std::env::current_exe()?)
|
||||
.args(["--exact", "proxy_child", "--nocapture"])
|
||||
.env("DURABLE_PROXY_DIR", &dir)
|
||||
.env(
|
||||
"PI_PROXY_BASE_URL",
|
||||
if start == 0 {
|
||||
"https://proxy.invalid"
|
||||
} else {
|
||||
"https://ignored.invalid"
|
||||
},
|
||||
)
|
||||
.env(
|
||||
"PI_PROXY_API_KEY",
|
||||
if start == 0 {
|
||||
"faux-private-token"
|
||||
} else {
|
||||
"ignored-token"
|
||||
},
|
||||
)
|
||||
.status()?;
|
||||
assert!(status.success());
|
||||
}
|
||||
let cred = Cred::open(&dir.join("host"))?;
|
||||
assert_eq!(
|
||||
cred.item_list()?
|
||||
.iter()
|
||||
.filter(|i| i.name == "llm-proxy")
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
for file in [
|
||||
"host/cred.sqlite",
|
||||
"host/cred.sqlite-wal",
|
||||
"host/harness.sqlite",
|
||||
"host/harness.sqlite-wal",
|
||||
] {
|
||||
if let Ok(bytes) = std::fs::read(dir.join(file)) {
|
||||
assert!(
|
||||
!bytes
|
||||
.windows(b"faux-private-token".len())
|
||||
.any(|w| w == b"faux-private-token")
|
||||
);
|
||||
}
|
||||
}
|
||||
assert!(!dir.join("world/conversation").exists());
|
||||
drop(cred);
|
||||
std::fs::remove_dir_all(dir)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn decisions_wake_idle_conversation() -> anyhow::Result<()> {
|
||||
for state in ["approved", "denied", "expired"] {
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("sausage-durable-{}-{state}", std::process::id()));
|
||||
let world = World::open(&dir)?;
|
||||
let cred = Arc::new(Cred::open(&world.host_dir)?);
|
||||
let admin = Caller {
|
||||
pid: 0,
|
||||
program: "test".into(),
|
||||
conversations: vec![],
|
||||
};
|
||||
let item = cred.create_item(
|
||||
Item {
|
||||
id: String::new(),
|
||||
name: "github".into(),
|
||||
version: 0,
|
||||
free_use: false,
|
||||
rules: vec![],
|
||||
validate_js: None,
|
||||
transform_js: "export default (ctx) => ctx.request".into(),
|
||||
fields: vec![],
|
||||
},
|
||||
&admin,
|
||||
"test",
|
||||
)?;
|
||||
cred.file_request(
|
||||
&admin,
|
||||
"program",
|
||||
GrantRequest {
|
||||
items: vec![item.id.clone()],
|
||||
duration_secs: None,
|
||||
max_uses: None,
|
||||
validator: None,
|
||||
},
|
||||
)?;
|
||||
let source = format!(
|
||||
"globalThis.expectedState = {state:?};\n{}",
|
||||
include_str!("../js/durable-test-bundle.js")
|
||||
);
|
||||
let decide = async {
|
||||
let conversation = loop {
|
||||
if let Ok(id) = std::fs::read_to_string(dir.join("world/conversation"))
|
||||
&& !id.is_empty()
|
||||
{
|
||||
break id;
|
||||
}
|
||||
tokio::task::yield_now().await;
|
||||
};
|
||||
let caller = Caller {
|
||||
conversations: vec![conversation],
|
||||
..admin
|
||||
};
|
||||
let id = cred.file_request(
|
||||
&caller,
|
||||
"test",
|
||||
GrantRequest {
|
||||
items: vec![item.id],
|
||||
duration_secs: None,
|
||||
max_uses: None,
|
||||
validator: None,
|
||||
},
|
||||
)?;
|
||||
let harness_db = rusqlite::Connection::open(dir.join("host/harness.sqlite"))?;
|
||||
loop {
|
||||
let count: i64 = harness_db.query_row(
|
||||
"SELECT count(*) FROM tasks WHERE json_extract(kind, '$')='sausage.credential.wait'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
if count == 1 {
|
||||
break;
|
||||
}
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
match state {
|
||||
"approved" => cred.approve(&id, "yes", Overrides::default(), Some("test"))?,
|
||||
"denied" => cred.deny(&id, "no", Some("test"))?,
|
||||
_ => {
|
||||
let db = rusqlite::Connection::open(dir.join("host/cred.sqlite"))?;
|
||||
db.execute("UPDATE requests SET expires=0 WHERE id=?", [&id])?;
|
||||
}
|
||||
}
|
||||
Ok::<_, anyhow::Error>(())
|
||||
};
|
||||
let exec = sausage::process::exec(World::open(&dir)?)?;
|
||||
tokio::time::timeout(std::time::Duration::from_secs(20), async {
|
||||
let (a, b) = tokio::join!(
|
||||
run_module_with_credentials(world, Arc::clone(&exec), cred.clone(), &source),
|
||||
decide
|
||||
);
|
||||
a?;
|
||||
b?;
|
||||
Ok::<_, anyhow::Error>(())
|
||||
})
|
||||
.await??;
|
||||
let db = rusqlite::Connection::open(dir.join("host/harness.sqlite"))?;
|
||||
let tasks: i64 = db.query_row(
|
||||
"SELECT count(*) FROM tasks WHERE json_extract(kind, '$')='sausage.credential.wait'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
assert_eq!(tasks, 1, "Program requests must create no durable task");
|
||||
drop(db);
|
||||
std::fs::remove_dir_all(dir)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user