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
47 changes: 47 additions & 0 deletions .github/workflows/cut-dojo-tarball.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
name: Cut Dojo fork tarball

# One-shot: build the fork on a CI runner (16GB) and publish the tarball as a
# GitHub Release, so the DojoOS gateway Dockerfile can pin it. Replaces the
# manual `npm pack` that OOMs on low-RAM laptops.

on:
workflow_dispatch:
inputs:
tag:
description: 'Release tag (e.g. v2026.5.22-dojo.3)'
required: true
notes:
description: 'Release notes (one line)'
required: false
default: 'Dojo fork tarball'

jobs:
cut:
runs-on: ubuntu-24.04
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
- name: Build fork + Control UI
run: |
pnpm build
pnpm ui:build
- name: Pack tarball
id: pack
run: |
npm pack
TGZ="$(ls openclaw-*.tgz)"
echo "tgz=$TGZ" >> "$GITHUB_OUTPUT"
echo "SHA256:"; sha256sum "$TGZ"
- name: Publish release
env:
GH_TOKEN: ${{ github.token }}
run: |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 P2 (major) — Shell injection and quoting vulnerability in workflow dispatch script.

Expanding workflow inputs directly via ${{ inputs.tag }} and ${{ inputs.notes }} inside inline shell scripts creates a potential script injection vector if special characters or quotes are included in the inputs. It also causes the step to fail with syntax errors if the notes contain double quotes.

Passing these inputs as environment variables securely prevents shell expansion and ensures quoting reliability.

[pass 1]

gh release create "${{ inputs.tag }}" "${{ steps.pack.outputs.tgz }}" \
--title "${{ inputs.tag }}" \
--notes "${{ inputs.notes }}"
echo "SHA256 for the plugin Dockerfile ARG:"
sha256sum "${{ steps.pack.outputs.tgz }}"
85 changes: 85 additions & 0 deletions extensions/google/transport-stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,10 +107,29 @@ type MutableAssistantOutput = {
timestamp: number;
responseId?: string;
errorMessage?: string;
webGrounding?: TransportWebGrounding;
};

const GOOGLE_VERTEX_DEFAULT_API_VERSION = "v1";

type GoogleGroundingMetadata = {
webSearchQueries?: string[];
groundingChunks?: Array<{
web?: { title?: string; uri?: string; domain?: string };
}>;
groundingSupports?: Array<{ groundingChunkIndices?: number[] }>;
};

/**
* Normalized Google Search grounding payload attached to the assistant
* output message. Consumers (gateway plugins, the OpenAI-compat endpoint)
* read it as `(message as { webGrounding?: TransportWebGrounding })`.
*/
export type TransportWebGrounding = {
queries: string[];
sources: Array<{ title: string; url: string; cited?: number }>;
};

type GoogleSseChunk = {
responseId?: string;
candidates?: Array<{
Expand All @@ -127,6 +146,7 @@ type GoogleSseChunk = {
}>;
};
finishReason?: string;
groundingMetadata?: GoogleGroundingMetadata;
}>;
usageMetadata?: {
promptTokenCount?: number;
Expand Down Expand Up @@ -690,6 +710,59 @@ function convertGoogleTools(tools: NonNullable<Context["tools"]>) {
];
}

/**
* Server-side Google Search grounding (opt-in via env). When enabled the
* request carries the `google_search` built-in tool; mixing it with
* `functionDeclarations` additionally requires
* `toolConfig.includeServerSideToolInvocations` (the API 400s without it).
* The resulting `groundingMetadata` is normalized onto the assistant output
* as `webGrounding`.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 P3 (minor) — Potential duplication and inflated citation counts during streaming re-entries.

If mergeWebGrounding is called multiple times across successive chunks in the stream (which can happen if multiple chunks contain the groundingMetadata block), the current logic will repeatedly find and append queries and add to existing.cited counts, causing citation numbers to inflate.

Since the metadata object from Gemini represents the complete state of search grounding rather than an incremental delta, we can avoid state-tracking bugs by completely overwriting output.webGrounding with a fresh object constructed from the latest metadata on each invocation.

[pass 1]

*/
function isGoogleSearchGroundingEnabled(): boolean {
const raw = process.env.OPENCLAW_GOOGLE_GROUNDING?.trim().toLowerCase();
return raw === "1" || raw === "true" || raw === "on";
}

function mergeWebGrounding(
output: MutableAssistantOutput,
metadata: GoogleGroundingMetadata,
): void {
const grounding = (output.webGrounding ??= { queries: [], sources: [] });
for (const query of metadata.webSearchQueries ?? []) {
if (typeof query === "string" && query && !grounding.queries.includes(query)) {
grounding.queries.push(query);
}
}
// Citation counts per chunk index (`groundingSupports`) let consumers rank
// sources by how often the answer actually cited them.
const citeCounts = new Map<number, number>();
for (const support of metadata.groundingSupports ?? []) {
for (const idx of support?.groundingChunkIndices ?? []) {
if (typeof idx === "number") {
citeCounts.set(idx, (citeCounts.get(idx) ?? 0) + 1);
}
}
}
const chunks = metadata.groundingChunks ?? [];
for (let idx = 0; idx < chunks.length; idx++) {
const chunk = chunks[idx];
const url = chunk?.web?.uri;
if (typeof url !== "string" || !url) {
continue;
}
const existing = grounding.sources.find((source) => source.url === url);
if (existing) {
existing.cited = (existing.cited ?? 0) + (citeCounts.get(idx) ?? 0);
continue;
}
grounding.sources.push({
title: chunk.web?.title || chunk.web?.domain || url,
url,
cited: citeCounts.get(idx) ?? 0,
});
}
}

export function buildGoogleGenerativeAiParams(
model: GoogleTransportModel,
context: Context,
Expand Down Expand Up @@ -734,6 +807,15 @@ export function buildGoogleGenerativeAiParams(
};
}
}
if (isGoogleSearchGroundingEnabled()) {
params.tools = [...(params.tools ?? []), { google_search: {} }];
if (context.tools?.length) {
params.toolConfig = {
...params.toolConfig,
includeServerSideToolInvocations: true,
};
}
}
return params;
}

Expand Down Expand Up @@ -1241,6 +1323,9 @@ function createGoogleTransportStreamFn(kind: CanonicalGoogleTransportApi): Strea
output.responseId ||= chunk.responseId;
updateUsage(output, model, chunk);
const candidate = chunk.candidates?.[0];
if (candidate?.groundingMetadata) {
mergeWebGrounding(output, candidate.groundingMetadata);
}
if (candidate?.content?.parts) {
for (const part of candidate.content.parts) {
const hasThoughtSignature =
Expand Down
20 changes: 20 additions & 0 deletions src/agents/pi-embedded-subscribe.handlers.messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -753,6 +753,26 @@ export function handleMessageEnd(
ctx.state.lastStreamedAssistantCleaned = cleanedText;
}

// Server-side Google Search grounding (OPENCLAW_GOOGLE_GROUNDING): the
// transport normalizes `groundingMetadata` onto the assistant message as
// `webGrounding`. Surface it as its own event stream so channel consumers
// (e.g. the OpenAI-compat endpoint) can forward the cited sources.
const webGrounding = (
assistantMessage as { webGrounding?: { queries: string[]; sources: unknown[] } }
).webGrounding;
if (webGrounding && webGrounding.sources.length > 0) {
const groundingData = webGrounding as unknown as Record<string, unknown>;
emitAgentEvent({
runId: ctx.params.runId,
stream: "grounding",
data: groundingData,
});
void ctx.params.onAgentEvent?.({
stream: "grounding",
data: groundingData,
});
}

const silentExpectedWithoutSentinel =
ctx.params.silentExpected && !isSilentReplyText(trimmedText, SILENT_REPLY_TOKEN);
const finalAssistantText = silentExpectedWithoutSentinel ? "" : text;
Expand Down
28 changes: 28 additions & 0 deletions src/gateway/openai-http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,25 @@ function writeUsageChunk(
});
}

/**
* Content-free chunk carrying Google Search grounding sources (queries +
* cited URLs) emitted by the `grounding` agent-event stream. Standard OpenAI
* clients ignore the unknown `dojo_grounding` delta field; the Dojo web-chat
* proxy consumes it to render a cited-sources card.
*/
function writeGroundingChunk(
res: ServerResponse,
params: { runId: string; model: string; grounding: Record<string, unknown> },
) {
writeSse(res, {
id: params.runId,
object: "chat.completion.chunk",
created: Math.floor(Date.now() / 1000),
model: params.model,
choices: [{ index: 0, delta: { dojo_grounding: params.grounding } }],
});
}

function asMessages(val: unknown): OpenAiChatMessage[] {
return Array.isArray(val) ? (val as OpenAiChatMessage[]) : [];
}
Expand Down Expand Up @@ -1105,6 +1124,15 @@ export async function handleOpenAiHttpRequest(
return;
}

if (evt.stream === "grounding") {
if (!wroteRole) {
wroteRole = true;
writeAssistantRoleChunk(res, { runId, model });
}
writeGroundingChunk(res, { runId, model, grounding: evt.data ?? {} });
return;
}

if (evt.stream === "assistant") {
const content = resolveAssistantStreamDeltaText(evt) ?? "";
if (!content) {
Expand Down
Loading