Skip to content
Closed
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
8 changes: 4 additions & 4 deletions ts/packages/agentRpc/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,7 @@ export async function createAgentRpcClient(
actionContextId: actionContextMap.getId(actionContext),
activityContext: actionContext.activityContext,
isFromReasoningLoop: actionContext.isFromReasoningLoop,
workingDirectory: actionContext.workingDirectory,
...getContextParam(actionContext.sessionContext),
});
} finally {
Expand All @@ -264,15 +265,14 @@ export async function createAgentRpcClient(
}
async function withActionContextAsync<T>(
actionContext: ActionContext<ShimContext>,
fn: (contextParams: {
actionContextId: number;
isFromReasoningLoop: boolean;
}) => Promise<T>,
fn: (contextParams: ActionContextParams) => Promise<T>,
) {
try {
return await fn({
actionContextId: actionContextMap.getId(actionContext),
activityContext: actionContext.activityContext,
isFromReasoningLoop: actionContext.isFromReasoningLoop,
workingDirectory: actionContext.workingDirectory,
...getContextParam(actionContext.sessionContext),
});
} finally {
Expand Down
1 change: 1 addition & 0 deletions ts/packages/agentRpc/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -812,6 +812,7 @@ export function createAgentRpcServer(
streamingContext: undefined,
activityContext: param.activityContext,
isFromReasoningLoop: param.isFromReasoningLoop ?? false,
workingDirectory: param.workingDirectory,
get abortSignal() {
return abortController.signal;
},
Expand Down
4 changes: 4 additions & 0 deletions ts/packages/agentRpc/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,10 @@ export type ActionContextParams = ContextParams & {
actionContextId: number;
activityContext: ActivityContext | undefined;
isFromReasoningLoop: boolean;
// Absolute filesystem root the host authorized for this action. Serialized
// across the RPC boundary so out-of-process agents see the same value as
// ActionContext.workingDirectory on the dispatcher side.
workingDirectory: string | undefined;
};

export type OptionsFunctionCallBack = {
Expand Down
84 changes: 84 additions & 0 deletions ts/packages/agentRpc/test/actionContext.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

import type {
ActionContext,
AppAgent,
SessionContext,
} from "@typeagent/agent-sdk";
import { createAgentRpcClient } from "../src/client.js";
import {
createChannelProviderAdapter,
type ChannelProviderAdapter,
} from "../src/common.js";
import { createAgentRpcServer } from "../src/server.js";

describe("agent action context RPC", () => {
test("propagates workingDirectory to the out-of-process agent", async () => {
let clientProvider: ChannelProviderAdapter;
let serverProvider: ChannelProviderAdapter;
clientProvider = createChannelProviderAdapter(
"test-client",
(message, callback) => {
queueMicrotask(() => serverProvider.notifyMessage(message));
callback?.(null);
},
);
serverProvider = createChannelProviderAdapter(
"test-server",
(message, callback) => {
queueMicrotask(() => clientProvider.notifyMessage(message));
callback?.(null);
},
);

let receivedWorkingDirectory: string | undefined;
const serverAgent: AppAgent = {
initializeAgentContext: async () => ({}),
executeAction: async (_action, context) => {
receivedWorkingDirectory = context.workingDirectory;
return undefined;
},
};
const server = createAgentRpcServer(
"test",
serverAgent,
serverProvider,
);
const clientAgent = await createAgentRpcClient(
"test",
clientProvider,
server.agentInterface,
);

try {
const agentContext = await clientAgent.initializeAgentContext?.();
const sessionContext = {
agentContext,
sessionContextId: "rpc-working-directory-test",
} as SessionContext<unknown>;
const actionContext = {
sessionContext,
workingDirectory: "C:\\host-authorized-workspace",
isFromReasoningLoop: false,
} as ActionContext<unknown>;

await clientAgent.executeAction?.(
{
schemaName: "test",
actionName: "test",
parameters: {},
},
actionContext,
);

expect(receivedWorkingDirectory).toBe(
"C:\\host-authorized-workspace",
);
} finally {
server.closeFn();
clientProvider.notifyDisconnected();
serverProvider.notifyDisconnected();
}
});
});
7 changes: 7 additions & 0 deletions ts/packages/agentSdk/src/agentInterface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -466,6 +466,13 @@ export interface ActionContext<T = void> {
// to execute immediately or redirect back to the reasoning loop.
readonly isFromReasoningLoop: boolean;

// Host-authorized filesystem root for this action, propagated from
// ProcessCommandOptions.workingDirectory. Agents that persist to disk
// (e.g. the markdown editor) should resolve document paths under this
// root instead of session storage. Undefined when the host did not
// supply a working directory (the request is not filesystem-scoped).
readonly workingDirectory?: string | undefined;

// queue up toggle transient agent to be executed at the end of the commands
queueToggleTransientAgent(
agentName: string,
Expand Down
1 change: 1 addition & 0 deletions ts/packages/agents/markdown/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@
"yjs": "^13.6.8"
},
"devDependencies": {
"@jest/globals": "^29.7.0",
"@milkdown/ctx": "^7.3.6",
"@types/debug": "^4.1.12",
"@types/express": "^4.17.17",
Expand Down
103 changes: 103 additions & 0 deletions ts/packages/agents/markdown/src/agent/boundPathAdoption.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

// Pure decision helper for adoptBoundPathFromView. Given a view-reported
// binding (boundFilePath / boundRoot / boundRelativePath), decide whether
// the agent can adopt it as its current document without widening trust.
//
// The gating rule is deliberately conservative: the reported root must
// either already be authorized in this process (via a prior create/open
// under a trusted ActionContext.workingDirectory) OR canonicalize to the
// same directory as the currently-supplied ActionContext.workingDirectory.
// A UI-synthesized ActionContext with no workingDirectory can never widen
// the trust boundary on its own, and no session/conversation storage is
// consulted.
//
// Kept as a pure function (I/O comes in via deps) so it can be unit
// tested without mocking child processes or leaning on process-global
// state.

import path from "node:path";

export type ViewBoundPathReport = {
boundFilePath: string | null | undefined;
boundRoot: string | null | undefined;
boundRelativePath?: string | null | undefined;
};

export type BoundPathAdoptionDeps = {
resolveRealDirectory(absolutePath: string): string | undefined;
resolveExistingFileWithinRoot(
root: string,
requestedPath: string,
): string | undefined;
isAuthorizedRoot(canonicalRoot: string): boolean;
authorizeRoot(canonicalRoot: string): void;
};

export type BoundPathAdoption = {
canonicalRoot: string;
// POSIX-style relative path; nested directory segments preserved so
// the caller can reconstruct the same user-relative name the view
// was already using.
relativePath: string;
resolvedAbsolute: string;
};

export function evaluateBoundPathAdoption(
report: ViewBoundPathReport,
actionContextWorkingDirectory: string | undefined,
deps: BoundPathAdoptionDeps,
): BoundPathAdoption | undefined {
const boundFilePath = report.boundFilePath ?? undefined;
const boundRoot = report.boundRoot ?? undefined;
const boundRelativePath = report.boundRelativePath ?? undefined;
if (
!boundFilePath ||
!boundRoot ||
!path.isAbsolute(boundFilePath) ||
!path.isAbsolute(boundRoot)
) {
return undefined;
}
const canonicalRoot = deps.resolveRealDirectory(boundRoot);
if (canonicalRoot === undefined) {
return undefined;
}
// Authorize the currently-supplied ActionContext.workingDirectory when
// it canonicalizes to the same root the view reports. Only fires when
// the caller actually supplied a workingDirectory, so a
// UI-synthesized ActionContext cannot promote an unapproved root.
if (typeof actionContextWorkingDirectory === "string") {
const acCanonical = deps.resolveRealDirectory(
actionContextWorkingDirectory,
);
if (acCanonical === canonicalRoot) {
deps.authorizeRoot(canonicalRoot);
}
}
if (!deps.isAuthorizedRoot(canonicalRoot)) {
return undefined;
}
// Prefer the full user-relative path the view sent (which preserves
// nested directories). Fall back to computing it from the absolute
// bound path only if the view did not supply one.
const relativeCandidate =
boundRelativePath ?? path.relative(canonicalRoot, boundFilePath);
const relativePath = relativeCandidate.split(path.sep).join("/");
if (
relativePath === "" ||
relativePath.startsWith("..") ||
path.isAbsolute(relativePath)
) {
return undefined;
}
const resolvedAbsolute = deps.resolveExistingFileWithinRoot(
canonicalRoot,
relativePath,
);
if (resolvedAbsolute === undefined) {
return undefined;
}
return { canonicalRoot, relativePath, resolvedAbsolute };
}
14 changes: 14 additions & 0 deletions ts/packages/agents/markdown/src/agent/contentRevision.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

// Compute a stable revision hash for a Markdown document. Both the agent and
// the view service compare revisions to detect that the browser edited the
// document between the agent's read (getDocumentContent) and its apply
// (applyLLMOperations). Hex-encoded SHA-256 is opaque, collision-resistant
// for this use, and produces short strings suitable for logging.

import { createHash } from "node:crypto";

export function computeContentRevision(content: string): string {
return createHash("sha256").update(content, "utf8").digest("hex");
}
Loading