diff --git a/ts/packages/agentSdk/src/action.ts b/ts/packages/agentSdk/src/action.ts index c5d0305620..67fd0be711 100644 --- a/ts/packages/agentSdk/src/action.ts +++ b/ts/packages/agentSdk/src/action.ts @@ -35,6 +35,13 @@ export type SerializedError = { export type ActionResultError = { error: string; fallbackToReasoning?: boolean | undefined; + // Stable machine-readable code for callers that need policy or retry + // decisions without parsing the display message. + errorCode?: string | undefined; + // Whether the caller may safely retry after changing the action. + retryable?: boolean | undefined; + // True when the failed action may already have changed external state. + mayHaveSideEffects?: boolean | undefined; // Rich display to show in place of the plain `error` text (e.g. setup // instructions with a config snippet, which need markdown to survive // rendering). Optional — clients fall back to `error` when absent. diff --git a/ts/packages/agents/powershell/benchmark/scenarios/dev-actions-routing.json b/ts/packages/agents/powershell/benchmark/scenarios/dev-actions-routing.json index b64792bd3e..f837979bec 100644 --- a/ts/packages/agents/powershell/benchmark/scenarios/dev-actions-routing.json +++ b/ts/packages/agents/powershell/benchmark/scenarios/dev-actions-routing.json @@ -31,7 +31,7 @@ { "id": "dev-route-02", "category": "dev-actions-routing", - "description": "PowerShell schema family includes the root flow schema", + "description": "PowerShell schema family includes the files namespace", "required": true, "setup": { "requiredFlows": ["listFiles"] @@ -51,7 +51,7 @@ "disposition": { "status": "handled", "path": "action", - "schemas": ["powershell"] + "schemas": ["powershell.powershell-files"] } } } diff --git a/ts/packages/agents/powershell/scripts/scriptHost.ps1 b/ts/packages/agents/powershell/scripts/scriptHost.ps1 index 9066eae1c3..3be563e216 100644 --- a/ts/packages/agents/powershell/scripts/scriptHost.ps1 +++ b/ts/packages/agents/powershell/scripts/scriptHost.ps1 @@ -27,6 +27,44 @@ param( $ErrorActionPreference = 'Stop' +function Remove-TrailingDirectorySeparator { + param([string]$Path) + + $root = [System.IO.Path]::GetPathRoot($Path) + if ($Path.Equals($root, [System.StringComparison]::OrdinalIgnoreCase)) { + return $root + } + return $Path.TrimEnd('\', '/') +} + +function Get-CanonicalFileSystemPath { + param([string]$Path) + + $fullPath = [System.IO.Path]::GetFullPath($Path) + if (Test-Path -LiteralPath $fullPath) { + $item = Get-Item -LiteralPath $fullPath -Force + return Remove-TrailingDirectorySeparator $item.FullName + } + + $missingSegments = [System.Collections.Generic.List[string]]::new() + $existingPath = $fullPath + while (-not (Test-Path -LiteralPath $existingPath)) { + $leaf = Split-Path -Leaf $existingPath + $parent = Split-Path -Parent $existingPath + if (-not $leaf -or -not $parent -or $parent -eq $existingPath) { + throw "Unable to resolve path '$Path'." + } + $missingSegments.Insert(0, $leaf) + $existingPath = $parent + } + + $canonicalPath = (Get-Item -LiteralPath $existingPath -Force).FullName + foreach ($segment in $missingSegments) { + $canonicalPath = Join-Path $canonicalPath $segment + } + return Remove-TrailingDirectorySeparator ([System.IO.Path]::GetFullPath($canonicalPath)) +} + try { $allowedCmdlets = $AllowedCmdletsJson | ConvertFrom-Json $params = $ParametersJson | ConvertFrom-Json @@ -50,9 +88,11 @@ try { $expandedAllowedPaths = @() foreach ($ap in $AllowedPaths) { try { - $expandedAllowedPaths += $ExecutionContext.InvokeCommand.ExpandString($ap) + $expandedPath = $ExecutionContext.InvokeCommand.ExpandString($ap) + $expandedAllowedPaths += Get-CanonicalFileSystemPath $expandedPath } catch { - $expandedAllowedPaths += $ap + Write-Error "Invalid allowed path '$ap': $_" + exit 1 } } @@ -73,26 +113,28 @@ try { continue } - $isValidPath = $false - try { $isValidPath = Test-Path $val -IsValid } catch { } - if ($isValidPath) { - $resolvedPath = $null - try { $resolvedPath = (Resolve-Path $val -ErrorAction SilentlyContinue).Path } catch {} - if ($resolvedPath) { - $pathAllowed = $false - foreach ($ap in $expandedAllowedPaths) { - if ($resolvedPath -like "$ap*") { - $pathAllowed = $true - break - } - } - # ENFORCEMENT: Block execution if path not allowed - if (-not $pathAllowed) { - Write-Error "Path access denied: '$resolvedPath' is not in allowedPaths. Allowed paths: $($expandedAllowedPaths -join ', ')" - exit 1 - } + try { + $resolvedPath = Get-CanonicalFileSystemPath $val + } catch { + Write-Error "Invalid path parameter '$($prop.Name)': $_" + exit 1 + } + $pathAllowed = $false + foreach ($ap in $expandedAllowedPaths) { + if ( + $resolvedPath.Equals($ap, [System.StringComparison]::OrdinalIgnoreCase) -or + $resolvedPath.StartsWith("$ap\", [System.StringComparison]::OrdinalIgnoreCase) -or + $resolvedPath.StartsWith("$ap/", [System.StringComparison]::OrdinalIgnoreCase) + ) { + $pathAllowed = $true + break } } + # ENFORCEMENT: Block execution if path not allowed + if (-not $pathAllowed) { + Write-Error "Path access denied: '$resolvedPath' is not in allowedPaths. Allowed paths: $($expandedAllowedPaths -join ', ')" + exit 1 + } } } } diff --git a/ts/packages/agents/powershell/src/actionHandler.mts b/ts/packages/agents/powershell/src/actionHandler.mts index 0780efcafb..f91fc6350b 100644 --- a/ts/packages/agents/powershell/src/actionHandler.mts +++ b/ts/packages/agents/powershell/src/actionHandler.mts @@ -38,121 +38,42 @@ import { executeScript, type ScriptExecutionRequest, } from "./execution/powershellRunner.mjs"; +import { + createPowerShellExecutionFailure, + createPowerShellFailure, +} from "./types/powerShellFailure.mjs"; +import type { PowerShellAgentContext } from "./types/powerShellAgentContext.mjs"; +import { executeNamespaceAction } from "./namespaces/actionHandlerRegistry.mjs"; +import type { PowerShellAction } from "./namespaces/namespaceActionHandler.mjs"; import registerDebug from "debug"; const debug = registerDebug("typeagent:powershell:handler"); const __dirname = dirname(fileURLToPath(import.meta.url)); const SAMPLES_DIR = join(__dirname, "..", "samples"); -interface PowerShellAgentContext { - store?: PowerShellStore | undefined; -} - -type StaticPowerShellAction = { - script: string; - allowedCmdlets: string[]; -}; - -const NETWORK_ACTIONS: Record = { - testConnection: { - script: `param([string]$ComputerName, [int]$Port) -if ($Port -gt 0) { - Test-NetConnection -ComputerName $ComputerName -Port $Port -} else { - Test-NetConnection -ComputerName $ComputerName -}`, - allowedCmdlets: ["Test-NetConnection"], - }, - portListeners: { - script: `param([int]$Port) -$listeners = Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue -if ($Port -gt 0) { - $listeners = $listeners | Where-Object { $_.LocalPort -eq $Port } -} -$listeners | - Sort-Object LocalPort, OwningProcess | - ForEach-Object { - $process = Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue - [PSCustomObject]@{ - LocalAddress = $_.LocalAddress - LocalPort = $_.LocalPort - ProcessId = $_.OwningProcess - ProcessName = if ($process) { $process.ProcessName } else { "(unknown)" } - } - }`, - allowedCmdlets: [ - "Get-NetTCPConnection", - "Where-Object", - "Sort-Object", - "ForEach-Object", - "Get-Process", - ], - }, - networkAdapters: { - script: `param([string]$Name) -if ($Name) { - Get-NetAdapter -Name $Name -} else { - Get-NetAdapter -}`, - allowedCmdlets: ["Get-NetAdapter"], - }, - ipConfig: { - script: `param([string]$InterfaceAlias) -if ($InterfaceAlias) { - Get-NetIPConfiguration -InterfaceAlias $InterfaceAlias -} else { - Get-NetIPConfiguration -}`, - allowedCmdlets: ["Get-NetIPConfiguration"], - }, - dnsLookup: { - script: `param([string]$Name, [string]$Type) -if ($Type) { - Resolve-DnsName -Name $Name -Type $Type -} else { - Resolve-DnsName -Name $Name -}`, - allowedCmdlets: ["Resolve-DnsName"], - }, -}; - -async function executeStaticNetworkAction(action: { - schemaName?: string; - actionName: string; - parameters?: Record; -}): Promise { - if (action.schemaName !== "powershell.powershell-network") { - return undefined; - } - const definition = NETWORK_ACTIONS[action.actionName]; - if (!definition) { - return undefined; - } - - const result = await executeScript({ - script: definition.script, - parameters: action.parameters ?? {}, - sandbox: { - allowedCmdlets: definition.allowedCmdlets, - allowedPaths: [], - allowedModules: [], - maxExecutionTime: 30, - networkAccess: true, - }, - workingDirectory: homedir(), +const flowMutationTails = new Map>(); +const repairAttempts = new WeakSet(); + +async function withFlowMutationLock( + flowName: string, + operation: () => Promise, +): Promise { + const previous = flowMutationTails.get(flowName) ?? Promise.resolve(); + let release: () => void; + const current = new Promise((resolve) => { + release = resolve; }); - - if (!result.success) { - return { - error: - result.stderr || `Script exited with code ${result.exitCode}`, - fallbackToReasoning: true, - }; + const tail = previous.then(() => current); + flowMutationTails.set(flowName, tail); + await previous; + try { + return await operation(); + } finally { + release!(); + if (flowMutationTails.get(flowName) === tail) { + flowMutationTails.delete(flowName); + } } - return createActionResultFromTextDisplay( - result.stdout.trim() || "(no output)", - ); } async function seedSampleFlows(store: PowerShellStore): Promise { @@ -189,6 +110,7 @@ async function executeFlowScript( flow: PowerShellFlowDefinition, script: string, parameters: Record, + abortSignal?: AbortSignal, ): Promise { const resolvedParams: Record = {}; for (const paramDef of flow.parameters) { @@ -210,18 +132,20 @@ async function executeFlowScript( }, // Use user's home directory as working directory for consistent path resolution workingDirectory: homedir(), + abortSignal, }; const result = await executeScript(request); + if (result.cancelled) { + abortSignal?.throwIfAborted(); + } if (result.success) { const output = result.stdout.trim() || "(no output)"; return createActionResultFromTextDisplay(output); } - const errorMsg = - result.stderr || `Script exited with code ${result.exitCode}`; - return { error: errorMsg, fallbackToReasoning: true }; + return createPowerShellExecutionFailure(result); } function mapParamsToFlowDefs( @@ -365,7 +289,11 @@ async function validateFlowGrammarPatterns( ? ["Suggestions:", ...validationResult.suggestions] : []), ].join("\n"); - return { error: createActionResultFromError(message) }; + return { + error: createPowerShellFailure("policyDenied", message, { + retryable: false, + }), + }; } if (validationResult.warnings?.length) { @@ -437,7 +365,8 @@ function parseNamedParameters( } if (typeof value !== "string") { return { - error: createActionResultFromError( + error: createPowerShellFailure( + "invalidParameters", `${parameterName} must be a JSON string`, ), }; @@ -454,7 +383,8 @@ function parseNamedParameters( return { parameters: parsed as Record }; } catch (error) { return { - error: createActionResultFromError( + error: createPowerShellFailure( + "invalidParameters", `Invalid JSON in ${parameterName}: ${error instanceof Error ? error.message : String(error)}`, ), }; @@ -464,6 +394,7 @@ function parseNamedParameters( async function executeDraftRecipe( recipe: ScriptRecipe, suppliedParameters: Record, + abortSignal?: AbortSignal, ): Promise<{ output: string } | { error: ActionResult }> { const executionParameters: Record = {}; mapParamsToFlowDefs( @@ -477,7 +408,12 @@ async function executeDraftRecipe( validatePathParameters(executionParameters, recipe.parameters) ?? validateParameterRules(executionParameters, recipe.parameters); if (validationError) { - return { error: createActionResultFromError(validationError) }; + return { + error: createPowerShellFailure( + "invalidParameters", + validationError, + ), + }; } const result = await executeScript({ @@ -485,28 +421,259 @@ async function executeDraftRecipe( parameters: executionParameters, sandbox: recipe.sandbox, workingDirectory: homedir(), + abortSignal, }); + if (result.cancelled) { + abortSignal?.throwIfAborted(); + } if (!result.success) { return { - error: createActionResultFromError( - result.stderr || `Script exited with code ${result.exitCode}`, - ), + error: createPowerShellExecutionFailure(result), }; } return { output: result.stdout.trim() || "(no output)" }; } +async function createOrReusePowerShellFlow( + params: Record, + flowStore: PowerShellStore, + context: ActionContext, +): Promise { + const actionName = params.actionName as string | undefined; + const script = params.script as string | undefined; + if (!actionName) { + return createPowerShellFailure( + "invalidParameters", + "Missing required parameter: actionName", + ); + } + if (!script) { + return createPowerShellFailure( + "invalidParameters", + "Missing required parameter: script", + ); + } + const executionParameters = parseNamedParameters( + params.executionParametersJson, + "executionParametersJson", + ); + if ("error" in executionParameters) { + return executionParameters.error; + } + + return withFlowMutationLock(actionName, async () => { + context.abortSignal?.throwIfAborted(); + const existing = await flowStore.getFlow(actionName); + if (existing) { + const existingScript = await flowStore.getScript(actionName); + if (!existingScript) { + return createPowerShellFailure( + "scriptFailure", + `Script not found for flow: ${actionName}`, + ); + } + const mappedParameters: Record = {}; + mapParamsToFlowDefs( + executionParameters.parameters, + existing.parameters, + mappedParameters, + ); + expandEnvVarsInParams(mappedParameters, existing.parameters); + const validationError = + validatePathParameters(mappedParameters, existing.parameters) ?? + validateParameterRules(mappedParameters, existing.parameters); + if (validationError) { + return createPowerShellFailure( + "invalidParameters", + validationError, + ); + } + const result = await executeFlowScript( + existing, + existingScript, + mappedParameters, + context.abortSignal, + ); + if (result.error === undefined) { + await flowStore.recordUsage(actionName); + } + return result; + } + + const grammarValidation = await validateFlowGrammarPatterns( + actionName, + (params.description as string) ?? "", + (params.grammarPatterns as FlowGrammarPatternInput[]) ?? [], + context, + ); + if ("error" in grammarValidation) { + return grammarValidation.error; + } + const recipe = buildPowerShellRecipe( + params, + grammarValidation.patterns, + ); + context.abortSignal?.throwIfAborted(); + const pendingId = await flowStore.savePending(recipe); + const pendingFile = `${pendingId}.recipe.json`; + let execution: Awaited>; + try { + execution = await executeDraftRecipe( + recipe, + executionParameters.parameters, + context.abortSignal, + ); + context.abortSignal?.throwIfAborted(); + } catch (error) { + await flowStore.deletePending(pendingFile); + throw error; + } + if ("error" in execution) { + await flowStore.deletePending(pendingFile); + return execution.error; + } + + const promoted = await flowStore.promotePending(pendingFile); + if (!promoted) { + await flowStore.deletePending(pendingFile); + return createPowerShellFailure( + "partialSideEffects", + `The script executed, but flow '${actionName}' could not be promoted because that name is already registered. The operation may have caused side effects and was not executed again.`, + ); + } + try { + context.abortSignal?.throwIfAborted(); + await context.sessionContext.reloadAgentSchema(); + context.abortSignal?.throwIfAborted(); + } catch (error) { + await flowStore.deleteFlow(promoted); + if (context.abortSignal?.aborted) { + context.abortSignal.throwIfAborted(); + } + return createPowerShellFailure( + "partialSideEffects", + `The script executed, but the new flow could not be activated: ${error instanceof Error ? error.message : String(error)}. The operation may have caused side effects and was not executed again.`, + ); + } + + return createActionResultFromTextDisplay( + `${execution.output}\n\nCreated reusable PowerShell flow '${promoted}'.`, + ); + }); +} + +async function repairAndExecutePowerShellFlow( + params: Record, + flowStore: PowerShellStore, + context: ActionContext, +): Promise { + const flowName = params.flowName as string | undefined; + const script = params.script as string | undefined; + if (!flowName || !script) { + return createPowerShellFailure( + "invalidParameters", + "Missing required parameter: flowName or script", + ); + } + const repairKey = context.abortSignal ?? context; + if (repairAttempts.has(repairKey)) { + return createPowerShellFailure( + "policyDenied", + "A PowerShell flow repair was already attempted for this request.", + { retryable: false }, + ); + } + repairAttempts.add(repairKey); + const executionParameters = parseNamedParameters( + params.executionParametersJson, + "executionParametersJson", + ); + if ("error" in executionParameters) { + return executionParameters.error; + } + + return withFlowMutationLock(flowName, async () => { + context.abortSignal?.throwIfAborted(); + const existing = await flowStore.getFlow(flowName); + const oldScript = await flowStore.getScript(flowName); + if (!existing || !oldScript) { + return createPowerShellFailure( + "unknownFlow", + `Unknown PowerShell flow '${flowName}'.`, + ); + } + const candidate: ScriptRecipe = { + version: 1, + actionName: existing.actionName, + displayName: existing.displayName, + description: existing.description, + parameters: existing.parameters, + script: { + language: "powershell", + body: script, + expectedOutputFormat: existing.expectedOutputFormat, + }, + grammarPatterns: existing.grammarPatterns, + sandbox: { + ...existing.sandbox, + allowedCmdlets: + (params.allowedCmdlets as string[]) ?? + existing.sandbox.allowedCmdlets, + allowedModules: + (params.allowedModules as string[]) ?? + existing.sandbox.allowedModules, + }, + ...(existing.source ? { source: existing.source } : {}), + }; + const execution = await executeDraftRecipe( + candidate, + executionParameters.parameters, + context.abortSignal, + ); + if ("error" in execution) { + return execution.error; + } + context.abortSignal?.throwIfAborted(); + await flowStore.updateFlowScript( + flowName, + script, + candidate.sandbox.allowedCmdlets, + candidate.sandbox.allowedModules, + ); + try { + context.abortSignal?.throwIfAborted(); + await context.sessionContext.reloadAgentSchema(); + context.abortSignal?.throwIfAborted(); + } catch (error) { + await flowStore.updateFlowScript( + flowName, + oldScript, + existing.sandbox.allowedCmdlets, + existing.sandbox.allowedModules, + ); + if (context.abortSignal?.aborted) { + context.abortSignal.throwIfAborted(); + } + return createPowerShellFailure( + "partialSideEffects", + `The repaired script executed, but the flow could not be activated: ${error instanceof Error ? error.message : String(error)}.`, + ); + } + await flowStore.recordUsage(flowName); + return createActionResultFromTextDisplay( + `${execution.output}\n\nRepaired PowerShell flow '${flowName}' after one retry.`, + ); + }); +} + async function handlePowerShellFlowAction( - action: { - schemaName?: string; - actionName: string; - parameters?: Record; - }, + action: PowerShellAction, context: ActionContext, ): Promise { - const networkResult = await executeStaticNetworkAction(action); - if (networkResult) { - return networkResult; + context.abortSignal?.throwIfAborted(); + const namespaceResult = await executeNamespaceAction(action, context); + if (namespaceResult !== undefined) { + return namespaceResult; } const flowStore = (context as any).__store as PowerShellStore | undefined; @@ -595,8 +762,16 @@ async function handlePowerShellFlowAction( grammarValidation.patterns, ); + context.abortSignal?.throwIfAborted(); await flowStore.saveFlow(recipe, "reasoning"); - await context.sessionContext.reloadAgentSchema(); + try { + context.abortSignal?.throwIfAborted(); + await context.sessionContext.reloadAgentSchema(); + context.abortSignal?.throwIfAborted(); + } catch (error) { + await flowStore.deleteFlow(newActionName); + throw error; + } return createActionResultFromTextDisplay( `Created PowerShell flow '${newActionName}': ${recipe.description}`, ); @@ -608,76 +783,11 @@ async function handlePowerShellFlowAction( "Script flow store not available", ); } - const params = action.parameters as Record; - const actionName = params.actionName as string | undefined; - const script = params.script as string | undefined; - if (!actionName) { - return createActionResultFromError( - "Missing required parameter: actionName", - ); - } - if (!script) { - return createActionResultFromError( - "Missing required parameter: script", - ); - } - if (flowStore.hasFlow(actionName)) { - return createActionResultFromError( - `A PowerShell flow named '${actionName}' already exists. Reuse it or add grammar patterns instead of creating a duplicate.`, - ); - } - - const grammarValidation = await validateFlowGrammarPatterns( - actionName, - (params.description as string) ?? "", - (params.grammarPatterns as FlowGrammarPatternInput[]) ?? [], + return createOrReusePowerShellFlow( + action.parameters as Record, + flowStore, context, ); - if ("error" in grammarValidation) { - return grammarValidation.error; - } - const executionParameters = parseNamedParameters( - params.executionParametersJson, - "executionParametersJson", - ); - if ("error" in executionParameters) { - return executionParameters.error; - } - - const recipe = buildPowerShellRecipe( - params, - grammarValidation.patterns, - ); - const pendingId = await flowStore.savePending(recipe); - const execution = await executeDraftRecipe( - recipe, - executionParameters.parameters, - ); - if ("error" in execution) { - await flowStore.deletePending(`${pendingId}.recipe.json`); - return execution.error; - } - - const pendingFile = `${pendingId}.recipe.json`; - const promoted = await flowStore.promotePending(pendingFile); - if (!promoted) { - await flowStore.deletePending(pendingFile); - return createActionResultFromError( - `The script executed, but flow '${actionName}' could not be promoted because that name is already registered. The operation may have caused side effects and was not executed again.`, - ); - } - try { - await context.sessionContext.reloadAgentSchema(); - } catch (error) { - await flowStore.deleteFlow(promoted); - return createActionResultFromError( - `The script executed, but the new flow could not be activated: ${error instanceof Error ? error.message : String(error)}. The operation may have caused side effects and was not executed again.`, - ); - } - - return createActionResultFromTextDisplay( - `${execution.output}\n\nCreated reusable PowerShell flow '${promoted}'.`, - ); } case "addPowerShellFlowPatterns": { @@ -731,6 +841,19 @@ async function handlePowerShellFlowAction( "PowerShell capability outcome reported.", ); + case "repairAndExecutePowerShellFlow": { + if (!flowStore) { + return createActionResultFromError( + "Script flow store not available", + ); + } + return repairAndExecutePowerShellFlow( + action.parameters as Record, + flowStore, + context, + ); + } + case "editPowerShellFlow": { if (!flowStore) { return createActionResultFromError( @@ -817,9 +940,13 @@ async function handlePowerShellFlowAction( networkAccess, }, workingDirectory: homedir(), + abortSignal: context.abortSignal, }; const result = await executeScript(request); + if (result.cancelled) { + context.abortSignal?.throwIfAborted(); + } if (result.success) { const output = result.stdout.trim() || "(no output)"; @@ -850,10 +977,10 @@ async function handlePowerShellFlowAction( const flow = await flowStore.getFlow(flowName); if (!flow) { - return { - error: `Unknown PowerShell flow '${flowName}'. Use 'listPowerShellFlows' to see available flows.`, - fallbackToReasoning: true, - }; + return createPowerShellFailure( + "unknownFlow", + `Unknown PowerShell flow '${flowName}'. Use 'listPowerShellFlows' to see available flows.`, + ); } const script = await flowStore.getScript(flowName); @@ -903,7 +1030,7 @@ async function handlePowerShellFlowAction( flow.parameters, ); if (pathError) { - return { error: pathError, fallbackToReasoning: true }; + return createPowerShellFailure("invalidParameters", pathError); } // Validate parameter validation rules (pattern, allowedValues) @@ -912,13 +1039,17 @@ async function handlePowerShellFlowAction( flow.parameters, ); if (validationError) { - return { error: validationError, fallbackToReasoning: true }; + return createPowerShellFailure( + "invalidParameters", + validationError, + ); } const result = await executeFlowScript( flow, script, flowParameters, + context.abortSignal, ); if (result.error !== undefined) { return { ...result, fallbackToReasoning: true }; @@ -993,8 +1124,16 @@ async function handlePowerShellFlowAction( ); } + context.abortSignal?.throwIfAborted(); await flowStore.saveFlow(recipe, "manual"); - await context.sessionContext.reloadAgentSchema(); + try { + context.abortSignal?.throwIfAborted(); + await context.sessionContext.reloadAgentSchema(); + context.abortSignal?.throwIfAborted(); + } catch (error) { + await flowStore.deleteFlow(recipe.actionName); + throw error; + } const patternList = recipe.grammarPatterns .map((p) => ` "${p.pattern}"`) @@ -1013,10 +1152,10 @@ async function handlePowerShellFlowAction( const flow = await flowStore.getFlow(action.actionName); if (!flow) { - return { - error: `Unknown PowerShell flow '${action.actionName}'. Use 'list PowerShell flows' to see available flows.`, - fallbackToReasoning: true, - }; + return createPowerShellFailure( + "unknownFlow", + `Unknown PowerShell flow '${action.actionName}'. Use 'list PowerShell flows' to see available flows.`, + ); } const script = await flowStore.getScript(action.actionName); @@ -1033,7 +1172,7 @@ async function handlePowerShellFlowAction( flow.parameters, ); if (pathError) { - return { error: pathError, fallbackToReasoning: true }; + return createPowerShellFailure("invalidParameters", pathError); } // Validate parameter validation rules (pattern, allowedValues) @@ -1042,10 +1181,18 @@ async function handlePowerShellFlowAction( flow.parameters, ); if (validationError) { - return { error: validationError, fallbackToReasoning: true }; + return createPowerShellFailure( + "invalidParameters", + validationError, + ); } - const result = await executeFlowScript(flow, script, directParams); + const result = await executeFlowScript( + flow, + script, + directParams, + context.abortSignal, + ); if (result.error !== undefined) { return { ...result, fallbackToReasoning: true }; } @@ -1368,6 +1515,7 @@ const POWERSHELL_BUILTIN_ACTIONS = new Set([ "createAndExecutePowerShellFlow", "addPowerShellFlowPatterns", "reportPowerShellCapabilityOutcome", + "repairAndExecutePowerShellFlow", "editPowerShellFlow", "importPowerShellFlow", ]); @@ -1415,11 +1563,7 @@ export function instantiate(): AppAgent { executeAction(action, context: ActionContext) { (context as any).__store = agentContext.store; return handlePowerShellFlowAction( - action as { - schemaName?: string; - actionName: string; - parameters?: Record; - }, + action as PowerShellAction, context, ); }, diff --git a/ts/packages/agents/powershell/src/execution/powershellRunner.mts b/ts/packages/agents/powershell/src/execution/powershellRunner.mts index c2ce0c1fd4..d64327a732 100644 --- a/ts/packages/agents/powershell/src/execution/powershellRunner.mts +++ b/ts/packages/agents/powershell/src/execution/powershellRunner.mts @@ -44,6 +44,7 @@ export interface ScriptExecutionRequest { networkAccess: boolean; }; workingDirectory?: string; + abortSignal?: AbortSignal | undefined; } export interface ScriptExecutionResult { @@ -53,11 +54,13 @@ export interface ScriptExecutionResult { exitCode: number; duration: number; truncated: boolean; + cancelled: boolean; } export async function executeScript( request: ScriptExecutionRequest, ): Promise { + request.abortSignal?.throwIfAborted(); const scriptHostPath = join(packageRoot, "scripts", "scriptHost.ps1"); const args = [ @@ -95,6 +98,7 @@ export async function executeScript( let stderr = ""; let truncated = false; let resolved = false; + let cancelled = false; const child = spawn("powershell", args, { cwd: request.workingDirectory, @@ -118,6 +122,7 @@ export async function executeScript( if (!resolved) { resolved = true; child.kill("SIGTERM"); + request.abortSignal?.removeEventListener("abort", onAbort); resolve({ success: false, stdout, @@ -125,21 +130,37 @@ export async function executeScript( exitCode: -1, duration: Date.now() - startTime, truncated, + cancelled: false, }); } }, request.sandbox.maxExecutionTime * 1000); + const onAbort = () => { + if (resolved) { + return; + } + cancelled = true; + child.kill("SIGTERM"); + }; + request.abortSignal?.addEventListener("abort", onAbort, { + once: true, + }); + child.on("close", (code) => { if (!resolved) { resolved = true; clearTimeout(timeout); + request.abortSignal?.removeEventListener("abort", onAbort); resolve({ - success: code === 0, + success: !cancelled && code === 0, stdout, - stderr, + stderr: cancelled + ? "PowerShell execution was cancelled." + : stderr, exitCode: code ?? -1, duration: Date.now() - startTime, truncated, + cancelled, }); } }); @@ -148,6 +169,7 @@ export async function executeScript( if (!resolved) { resolved = true; clearTimeout(timeout); + request.abortSignal?.removeEventListener("abort", onAbort); resolve({ success: false, stdout, @@ -155,6 +177,7 @@ export async function executeScript( exitCode: -1, duration: Date.now() - startTime, truncated, + cancelled: false, }); } }); diff --git a/ts/packages/agents/powershell/src/manifest.json b/ts/packages/agents/powershell/src/manifest.json index 75d50b61cf..f07693cc83 100644 --- a/ts/packages/agents/powershell/src/manifest.json +++ b/ts/packages/agents/powershell/src/manifest.json @@ -11,6 +11,46 @@ "schemaType": "PowerShellActions" }, "subActionManifests": { + "powershell-files": { + "defaultEnabled": true, + "schema": { + "description": "PowerShell actions for listing, reading, writing, copying, moving, deleting, searching, and creating files and directories.", + "originalSchemaFile": "./namespaces/files/filesActionsSchema.mts", + "schemaFile": "../dist/filesSchema.pas.json", + "grammarFile": "../dist/filesSchema.ag.json", + "schemaType": "PowerShellFilesActions" + } + }, + "powershell-processes": { + "defaultEnabled": true, + "schema": { + "description": "PowerShell actions for listing, inspecting, starting, stopping, and waiting for processes.", + "originalSchemaFile": "./namespaces/processes/processesActionsSchema.mts", + "schemaFile": "../dist/processesSchema.pas.json", + "grammarFile": "../dist/processesSchema.ag.json", + "schemaType": "PowerShellProcessesActions" + } + }, + "powershell-system": { + "defaultEnabled": true, + "schema": { + "description": "PowerShell actions for system information, disk usage, updates, uptime, and environment variables.", + "originalSchemaFile": "./namespaces/system/systemActionsSchema.mts", + "schemaFile": "../dist/systemSchema.pas.json", + "grammarFile": "../dist/systemSchema.ag.json", + "schemaType": "PowerShellSystemActions" + } + }, + "powershell-services": { + "defaultEnabled": true, + "schema": { + "description": "PowerShell actions for listing, inspecting, starting, stopping, and restarting Windows services.", + "originalSchemaFile": "./namespaces/services/servicesActionsSchema.mts", + "schemaFile": "../dist/servicesSchema.pas.json", + "grammarFile": "../dist/servicesSchema.ag.json", + "schemaType": "PowerShellServicesActions" + } + }, "powershell-network": { "defaultEnabled": true, "schema": { @@ -20,6 +60,26 @@ "grammarFile": "../dist/networkSchema.ag.json", "schemaType": "PowerShellNetworkActions" } + }, + "powershell-data": { + "defaultEnabled": true, + "schema": { + "description": "PowerShell actions for reading, writing, filtering, and converting JSON and CSV data.", + "originalSchemaFile": "./namespaces/data/dataActionsSchema.mts", + "schemaFile": "../dist/dataSchema.pas.json", + "grammarFile": "../dist/dataSchema.ag.json", + "schemaType": "PowerShellDataActions" + } + }, + "powershell-archives": { + "defaultEnabled": true, + "schema": { + "description": "PowerShell actions for compressing and extracting ZIP archives.", + "originalSchemaFile": "./namespaces/archives/archivesActionsSchema.mts", + "schemaFile": "../dist/archivesSchema.pas.json", + "grammarFile": "../dist/archivesSchema.ag.json", + "schemaType": "PowerShellArchivesActions" + } } } } diff --git a/ts/packages/agents/powershell/src/namespaces/actionHandlerRegistry.mts b/ts/packages/agents/powershell/src/namespaces/actionHandlerRegistry.mts new file mode 100644 index 0000000000..c3371d7ffe --- /dev/null +++ b/ts/packages/agents/powershell/src/namespaces/actionHandlerRegistry.mts @@ -0,0 +1,65 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { ActionContext, ActionResult } from "@typeagent/agent-sdk"; +import type { PowerShellAgentContext } from "../types/powerShellAgentContext.mjs"; +import { archivesActionHandler } from "./archives/actionHandler.mjs"; +import { dataActionHandler } from "./data/actionHandler.mjs"; +import { filesActionHandler } from "./files/actionHandler.mjs"; +import { networkActionHandler } from "./network/actionHandler.mjs"; +import type { + PowerShellAction, + PowerShellNamespaceActionHandler, +} from "./namespaceActionHandler.mjs"; +import { processesActionHandler } from "./processes/actionHandler.mjs"; +import { servicesActionHandler } from "./services/actionHandler.mjs"; +import { systemActionHandler } from "./system/actionHandler.mjs"; + +const handlers = [ + archivesActionHandler, + dataActionHandler, + filesActionHandler, + networkActionHandler, + processesActionHandler, + servicesActionHandler, + systemActionHandler, +] as const; + +const handlersBySchema = new Map(); +for (const handler of handlers) { + if (handlersBySchema.has(handler.schemaName)) { + throw new Error( + `Duplicate PowerShell namespace handler: ${handler.schemaName}`, + ); + } + handlersBySchema.set(handler.schemaName, handler); +} + +export async function executeNamespaceAction( + action: PowerShellAction, + context: ActionContext, +): Promise { + if (!action.schemaName) { + return undefined; + } + return handlersBySchema.get(action.schemaName)?.execute(action, context); +} + +export function hasNamespaceAction( + schemaName: string, + actionName: string, +): boolean { + return handlersBySchema.get(schemaName)?.hasAction(actionName) ?? false; +} + +export function getRegisteredNamespaceActions(): ReadonlyMap< + string, + readonly string[] +> { + return new Map( + [...handlersBySchema].map(([schemaName, handler]) => [ + schemaName, + handler.actionNames, + ]), + ); +} diff --git a/ts/packages/agents/powershell/src/namespaces/archives/actionHandler.mts b/ts/packages/agents/powershell/src/namespaces/archives/actionHandler.mts new file mode 100644 index 0000000000..0d4854796f --- /dev/null +++ b/ts/packages/agents/powershell/src/namespaces/archives/actionHandler.mts @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { PowerShellArchivesActions } from "./archivesActionsSchema.mjs"; +import { + createPowerShellNamespaceActionHandler, + type NamespaceActionDefinitions, +} from "../namespaceActionHandler.mjs"; + +const allowedPaths = ["$env:USERPROFILE", "$PWD", "$env:TEMP"] as const; + +const definitions = { + compress: { + script: `param([string]$SourcePath, [string]$DestinationPath) +Add-Type -AssemblyName System.IO.Compression +Add-Type -AssemblyName System.IO.Compression.FileSystem +if (-not $DestinationPath) { $DestinationPath = "$SourcePath.zip" } +if ([System.IO.Directory]::Exists($SourcePath)) { + [System.IO.Compression.ZipFile]::CreateFromDirectory($SourcePath, $DestinationPath) +} else { + $archive = [System.IO.Compression.ZipFile]::Open( + $DestinationPath, + [System.IO.Compression.ZipArchiveMode]::Create + ) + try { + [System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile( + $archive, + $SourcePath, + [System.IO.Path]::GetFileName($SourcePath) + ) | Out-Null + } finally { + $archive.Dispose() + } +}`, + allowedCmdlets: ["Add-Type", "Out-Null"], + allowedPaths, + confirmation: "Create the requested ZIP archive?", + }, + expand: { + script: `param([string]$ArchivePath, [string]$DestinationPath) +Add-Type -AssemblyName System.IO.Compression +Add-Type -AssemblyName System.IO.Compression.FileSystem +if (-not $DestinationPath) { $DestinationPath = "." } +[System.IO.Compression.ZipFile]::ExtractToDirectory($ArchivePath, $DestinationPath)`, + allowedCmdlets: ["Add-Type"], + allowedPaths, + confirmation: "Extract the requested archive?", + }, +} satisfies NamespaceActionDefinitions; + +export const archivesActionHandler = + createPowerShellNamespaceActionHandler( + "powershell.powershell-archives", + definitions, + ); diff --git a/ts/packages/agents/powershell/src/namespaces/data/actionHandler.mts b/ts/packages/agents/powershell/src/namespaces/data/actionHandler.mts new file mode 100644 index 0000000000..9e357a0183 --- /dev/null +++ b/ts/packages/agents/powershell/src/namespaces/data/actionHandler.mts @@ -0,0 +1,83 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { PowerShellDataActions } from "./dataActionsSchema.mjs"; +import { + createPowerShellNamespaceActionHandler, + type NamespaceActionDefinitions, +} from "../namespaceActionHandler.mjs"; + +const allowedPaths = ["$env:USERPROFILE", "$PWD", "$env:TEMP"] as const; + +const definitions = { + readJson: { + script: `param([string]$Path, [string]$PropertyPath) +$value = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json +if ($PropertyPath) { + foreach ($part in $PropertyPath.Split(".")) { $value = $value.$part } +} +$value`, + allowedCmdlets: ["Get-Content", "ConvertFrom-Json"], + allowedPaths, + }, + writeJson: { + script: `param([string]$Path, [string]$Data) +$Data | ConvertFrom-Json | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $Path`, + allowedCmdlets: ["ConvertFrom-Json", "ConvertTo-Json", "Set-Content"], + allowedPaths, + confirmation: "Write JSON data to the requested file?", + }, + readCsv: { + script: `param([string]$Path, [string]$Delimiter) +if (-not $Delimiter) { $Delimiter = "," } +Import-Csv -LiteralPath $Path -Delimiter $Delimiter`, + allowedCmdlets: ["Import-Csv"], + allowedPaths, + }, + writeCsv: { + script: `param([string]$Path, [string]$Data) +$Data | ConvertFrom-Json | Export-Csv -LiteralPath $Path -NoTypeInformation`, + allowedCmdlets: ["ConvertFrom-Json", "Export-Csv"], + allowedPaths, + confirmation: "Write CSV data to the requested file?", + }, + filterCsv: { + script: `param([string]$Path, [string]$Column, [string]$Pattern) +Import-Csv -LiteralPath $Path | Where-Object { $_.$Column -match $Pattern }`, + allowedCmdlets: ["Import-Csv", "Where-Object"], + allowedPaths, + }, + convertFormat: { + script: `param([string]$Input, [string]$Format) +$extension = [System.IO.Path]::GetExtension($Input).ToLowerInvariant() +$value = if ($extension -eq ".csv") { + Import-Csv -LiteralPath $Input +} elseif ($extension -eq ".json") { + Get-Content -LiteralPath $Input -Raw | ConvertFrom-Json +} else { + Get-Content -LiteralPath $Input -Raw +} +if ($Format -eq "json") { + $value | ConvertTo-Json -Depth 20 +} elseif ($Format -eq "csv") { + $value | ConvertTo-Csv -NoTypeInformation +} else { + $value | ConvertTo-Xml -As String -Depth 20 +}`, + allowedCmdlets: [ + "Import-Csv", + "Get-Content", + "ConvertFrom-Json", + "ConvertTo-Json", + "ConvertTo-Csv", + "ConvertTo-Xml", + ], + allowedPaths, + }, +} satisfies NamespaceActionDefinitions; + +export const dataActionHandler = + createPowerShellNamespaceActionHandler( + "powershell.powershell-data", + definitions, + ); diff --git a/ts/packages/agents/powershell/src/namespaces/data/dataSchema.agr b/ts/packages/agents/powershell/src/namespaces/data/dataSchema.agr index a2486f9502..0071bcae01 100644 --- a/ts/packages/agents/powershell/src/namespaces/data/dataSchema.agr +++ b/ts/packages/agents/powershell/src/namespaces/data/dataSchema.agr @@ -18,7 +18,7 @@ import { PowerShellDataActions } from "./dataActionsSchema.mts"; -> { actionName: "readJson", parameters: { path } } | (read|get) $(propertyPath:wildcard) from (json)? $(path:wildcard) -> { actionName: "readJson", parameters: { path, propertyPath } } - | (show|display) (json)? (file)? $(path:wildcard) + | (show|display) (json|json file) $(path:wildcard) -> { actionName: "readJson", parameters: { path } }; = diff --git a/ts/packages/agents/powershell/src/namespaces/files/actionHandler.mts b/ts/packages/agents/powershell/src/namespaces/files/actionHandler.mts new file mode 100644 index 0000000000..a7784ca0e3 --- /dev/null +++ b/ts/packages/agents/powershell/src/namespaces/files/actionHandler.mts @@ -0,0 +1,99 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { PowerShellFilesActions } from "./filesActionsSchema.mjs"; +import { + createPowerShellNamespaceActionHandler, + type NamespaceActionDefinitions, +} from "../namespaceActionHandler.mjs"; + +const allowedPaths = ["$env:USERPROFILE", "$PWD", "$env:TEMP"] as const; + +const definitions = { + listFiles: { + script: `param([string]$Path, [string]$Filter, [bool]$Recurse) +if (-not $Path) { $Path = "." } +if ($Filter) { + Get-ChildItem -Path $Path -Filter $Filter -Recurse:$Recurse +} else { + Get-ChildItem -Path $Path -Recurse:$Recurse +}`, + allowedCmdlets: ["Get-ChildItem"], + allowedPaths, + }, + readFile: { + script: `param([string]$Path, [int]$Tail, [int]$Head) +if ($Tail -gt 0) { + Get-Content -LiteralPath $Path -Tail $Tail +} elseif ($Head -gt 0) { + Get-Content -LiteralPath $Path -TotalCount $Head +} else { + Get-Content -LiteralPath $Path +}`, + allowedCmdlets: ["Get-Content"], + allowedPaths, + }, + writeFile: { + script: `param([string]$Path, [string]$Content, [bool]$Append) +if ($Append) { + Add-Content -LiteralPath $Path -Value $Content +} else { + Set-Content -LiteralPath $Path -Value $Content +}`, + allowedCmdlets: ["Add-Content", "Set-Content"], + allowedPaths, + confirmation: "Write content to the requested file?", + }, + copyFile: { + script: `param([string]$Source, [string]$Destination, [bool]$Recurse) +Copy-Item -LiteralPath $Source -Destination $Destination -Recurse:$Recurse`, + allowedCmdlets: ["Copy-Item"], + allowedPaths, + confirmation: "Copy the requested file or directory?", + }, + moveFile: { + script: `param([string]$Source, [string]$Destination) +Move-Item -LiteralPath $Source -Destination $Destination`, + allowedCmdlets: ["Move-Item"], + allowedPaths, + confirmation: "Move or rename the requested file or directory?", + }, + deleteFile: { + script: `param([string]$Path, [bool]$Recurse) +Remove-Item -LiteralPath $Path -Recurse:$Recurse`, + allowedCmdlets: ["Remove-Item"], + allowedPaths, + confirmation: "Delete the requested file or directory?", + }, + testPath: { + script: `param([string]$Path) +Test-Path -LiteralPath $Path`, + allowedCmdlets: ["Test-Path"], + allowedPaths, + }, + findText: { + script: `param([string]$Pattern, [string]$Path, [string]$Include) +if (-not $Path) { $Path = "." } +if ($Include) { + Get-ChildItem -Path $Path -Filter $Include -File -Recurse | Select-String -Pattern $Pattern +} else { + Get-ChildItem -Path $Path -File -Recurse | Select-String -Pattern $Pattern +}`, + allowedCmdlets: ["Get-ChildItem", "Select-String"], + allowedPaths, + }, + newItem: { + script: `param([string]$Path, [string]$ItemType) +$type = if ($ItemType -eq "directory") { "Directory" } else { "File" } +New-Item -Path $Path -ItemType $type`, + allowedCmdlets: ["New-Item"], + allowedPaths, + confirmation: "Create the requested file or directory?", + }, +} satisfies NamespaceActionDefinitions; + +export const filesActionHandler = + createPowerShellNamespaceActionHandler( + "powershell.powershell-files", + definitions, + ); diff --git a/ts/packages/agents/powershell/src/namespaces/files/filesSchema.agr b/ts/packages/agents/powershell/src/namespaces/files/filesSchema.agr index 8126d8774b..62d49e8d3d 100644 --- a/ts/packages/agents/powershell/src/namespaces/files/filesSchema.agr +++ b/ts/packages/agents/powershell/src/namespaces/files/filesSchema.agr @@ -29,7 +29,9 @@ import { PowerShellFilesActions } from "./filesActionsSchema.mts"; -> { actionName: "listFiles", parameters: { path } }; = - (read|show|display|cat|type) (the)? (contents of|file)? $(path:wildcard) + (read|cat|type) (the)? (contents of|file)? $(path:wildcard) + -> { actionName: "readFile", parameters: { path } } + | (show|display) (the)? (contents of|file) $(path:wildcard) -> { actionName: "readFile", parameters: { path } } | (show|get) (the)? (last|tail) $(tail:number) lines (of|from|in) $(path:wildcard) -> { actionName: "readFile", parameters: { path, tail } } diff --git a/ts/packages/agents/powershell/src/namespaces/namespaceActionHandler.mts b/ts/packages/agents/powershell/src/namespaces/namespaceActionHandler.mts new file mode 100644 index 0000000000..fcc821f044 --- /dev/null +++ b/ts/packages/agents/powershell/src/namespaces/namespaceActionHandler.mts @@ -0,0 +1,113 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { ActionContext, ActionResult } from "@typeagent/agent-sdk"; +import { createActionResultFromTextDisplay } from "@typeagent/agent-sdk/helpers/action"; +import { homedir } from "os"; +import { executeScript } from "../execution/powershellRunner.mjs"; +import type { PowerShellAgentContext } from "../types/powerShellAgentContext.mjs"; +import { + createPowerShellExecutionFailure, + createPowerShellFailure, +} from "../types/powerShellFailure.mjs"; + +export type PowerShellAction = { + schemaName?: string; + actionName: string; + parameters?: Record; +}; + +export type StaticPowerShellActionDefinition = { + script: string; + allowedCmdlets: readonly string[]; + allowedPaths?: readonly string[]; + allowedModules?: readonly string[]; + networkAccess?: boolean; + maxExecutionTime?: number; + confirmation?: string; +}; + +export type NamespaceActionDefinitions = + { + [Name in TAction["actionName"]]: StaticPowerShellActionDefinition; + }; + +export interface PowerShellNamespaceActionHandler { + readonly schemaName: string; + readonly actionNames: readonly string[]; + hasAction(actionName: string): boolean; + execute( + action: PowerShellAction, + context: ActionContext, + ): Promise; +} + +export function createPowerShellNamespaceActionHandler< + TAction extends { actionName: string }, +>( + schemaName: string, + definitions: NamespaceActionDefinitions, +): PowerShellNamespaceActionHandler { + const actionDefinitions = definitions as Record< + string, + StaticPowerShellActionDefinition + >; + const actionNames = Object.freeze(Object.keys(actionDefinitions)); + + return { + schemaName, + actionNames, + hasAction(actionName: string): boolean { + return actionDefinitions[actionName] !== undefined; + }, + async execute( + action: PowerShellAction, + context: ActionContext, + ): Promise { + if (action.schemaName !== schemaName) { + return undefined; + } + const definition = actionDefinitions[action.actionName]; + if (!definition) { + return undefined; + } + if (definition.confirmation) { + const choice = await context.sessionContext.popupQuestion( + definition.confirmation, + ["Run", "Cancel"], + 1, + ); + if (choice !== 0) { + return createPowerShellFailure( + "policyDenied", + "The PowerShell action was not approved.", + { retryable: false }, + ); + } + } + + const result = await executeScript({ + script: definition.script, + parameters: action.parameters ?? {}, + sandbox: { + allowedCmdlets: [...definition.allowedCmdlets], + allowedPaths: [...(definition.allowedPaths ?? [])], + allowedModules: [...(definition.allowedModules ?? [])], + maxExecutionTime: definition.maxExecutionTime ?? 30, + networkAccess: definition.networkAccess ?? false, + }, + workingDirectory: homedir(), + abortSignal: context.abortSignal, + }); + if (result.cancelled) { + context.abortSignal?.throwIfAborted(); + } + if (!result.success) { + return createPowerShellExecutionFailure(result); + } + return createActionResultFromTextDisplay( + result.stdout.trim() || "(no output)", + ); + }, + }; +} diff --git a/ts/packages/agents/powershell/src/namespaces/network/actionHandler.mts b/ts/packages/agents/powershell/src/namespaces/network/actionHandler.mts new file mode 100644 index 0000000000..e6f91c7844 --- /dev/null +++ b/ts/packages/agents/powershell/src/namespaces/network/actionHandler.mts @@ -0,0 +1,83 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { PowerShellNetworkActions } from "./networkActionsSchema.mjs"; +import { + createPowerShellNamespaceActionHandler, + type NamespaceActionDefinitions, +} from "../namespaceActionHandler.mjs"; + +const definitions = { + testConnection: { + script: `param([string]$ComputerName, [int]$Port) +if ($Port -gt 0) { + Test-NetConnection -ComputerName $ComputerName -Port $Port +} else { + Test-NetConnection -ComputerName $ComputerName +}`, + allowedCmdlets: ["Test-NetConnection"], + networkAccess: true, + }, + portListeners: { + script: `param([int]$Port) +$listeners = Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue +if ($Port -gt 0) { + $listeners = $listeners | Where-Object { $_.LocalPort -eq $Port } +} +$listeners | + Sort-Object LocalPort, OwningProcess | + ForEach-Object { + $process = Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue + [PSCustomObject]@{ + LocalAddress = $_.LocalAddress + LocalPort = $_.LocalPort + ProcessId = $_.OwningProcess + ProcessName = if ($process) { $process.ProcessName } else { "(unknown)" } + } + }`, + allowedCmdlets: [ + "Get-NetTCPConnection", + "Where-Object", + "Sort-Object", + "ForEach-Object", + "Get-Process", + ], + networkAccess: true, + }, + networkAdapters: { + script: `param([string]$Name) +if ($Name) { + Get-NetAdapter -Name $Name +} else { + Get-NetAdapter +}`, + allowedCmdlets: ["Get-NetAdapter"], + networkAccess: true, + }, + ipConfig: { + script: `param([string]$InterfaceAlias) +if ($InterfaceAlias) { + Get-NetIPConfiguration -InterfaceAlias $InterfaceAlias +} else { + Get-NetIPConfiguration +}`, + allowedCmdlets: ["Get-NetIPConfiguration"], + networkAccess: true, + }, + dnsLookup: { + script: `param([string]$Name, [string]$Type) +if ($Type) { + Resolve-DnsName -Name $Name -Type $Type +} else { + Resolve-DnsName -Name $Name +}`, + allowedCmdlets: ["Resolve-DnsName"], + networkAccess: true, + }, +} satisfies NamespaceActionDefinitions; + +export const networkActionHandler = + createPowerShellNamespaceActionHandler( + "powershell.powershell-network", + definitions, + ); diff --git a/ts/packages/agents/powershell/src/namespaces/processes/actionHandler.mts b/ts/packages/agents/powershell/src/namespaces/processes/actionHandler.mts new file mode 100644 index 0000000000..f72158d766 --- /dev/null +++ b/ts/packages/agents/powershell/src/namespaces/processes/actionHandler.mts @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { PowerShellProcessesActions } from "./processesActionsSchema.mjs"; +import { + createPowerShellNamespaceActionHandler, + type NamespaceActionDefinitions, +} from "../namespaceActionHandler.mjs"; + +const definitions = { + listProcesses: { + script: `param([string]$Name, [int]$TopN) +$items = if ($Name) { Get-Process -Name $Name } else { Get-Process } +if ($TopN -gt 0) { $items | Select-Object -First $TopN } else { $items }`, + allowedCmdlets: ["Get-Process", "Select-Object"], + }, + processMemory: { + script: `param([int]$TopN, [string]$Name) +if ($TopN -le 0) { $TopN = 10 } +$items = if ($Name) { Get-Process -Name $Name } else { Get-Process } +$items | Sort-Object WorkingSet64 -Descending | Select-Object -First $TopN Name, Id, WorkingSet64`, + allowedCmdlets: ["Get-Process", "Sort-Object", "Select-Object"], + }, + processCpu: { + script: `param([int]$TopN, [string]$Name) +if ($TopN -le 0) { $TopN = 10 } +$items = if ($Name) { Get-Process -Name $Name } else { Get-Process } +$items | Sort-Object CPU -Descending | Select-Object -First $TopN Name, Id, CPU`, + allowedCmdlets: ["Get-Process", "Sort-Object", "Select-Object"], + }, + stopProcess: { + script: `param([string]$Name, [int]$Id) +if ($Id -gt 0) { Stop-Process -Id $Id } else { Stop-Process -Name $Name }`, + allowedCmdlets: ["Stop-Process"], + confirmation: "Stop the requested process?", + }, + startProcess: { + script: `param([string]$Path, [string]$Arguments) +if ($Arguments) { Start-Process -FilePath $Path -ArgumentList $Arguments } else { Start-Process -FilePath $Path }`, + allowedCmdlets: ["Start-Process"], + allowedPaths: ["$env:USERPROFILE", "$PWD", "$env:TEMP"], + confirmation: "Start the requested process?", + }, + waitProcess: { + script: `param([string]$Name, [int]$Id) +if ($Id -gt 0) { Wait-Process -Id $Id } else { Wait-Process -Name $Name }`, + allowedCmdlets: ["Wait-Process"], + }, +} satisfies NamespaceActionDefinitions; + +export const processesActionHandler = + createPowerShellNamespaceActionHandler( + "powershell.powershell-processes", + definitions, + ); diff --git a/ts/packages/agents/powershell/src/namespaces/services/actionHandler.mts b/ts/packages/agents/powershell/src/namespaces/services/actionHandler.mts new file mode 100644 index 0000000000..76603e4bd0 --- /dev/null +++ b/ts/packages/agents/powershell/src/namespaces/services/actionHandler.mts @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { PowerShellServicesActions } from "./servicesActionsSchema.mjs"; +import { + createPowerShellNamespaceActionHandler, + type NamespaceActionDefinitions, +} from "../namespaceActionHandler.mjs"; + +const definitions = { + listServices: { + script: `param([string]$Name, [string]$Status) +$services = if ($Name) { Get-Service -Name $Name } else { Get-Service } +if ($Status) { $services | Where-Object { $_.Status.ToString() -eq $Status } } else { $services }`, + allowedCmdlets: ["Get-Service", "Where-Object"], + }, + serviceStatus: { + script: `param([string]$Name) +Get-Service -Name $Name`, + allowedCmdlets: ["Get-Service"], + }, + startService: { + script: `param([string]$Name) +Start-Service -Name $Name`, + allowedCmdlets: ["Start-Service"], + confirmation: "Start the requested Windows service?", + }, + stopService: { + script: `param([string]$Name) +Stop-Service -Name $Name`, + allowedCmdlets: ["Stop-Service"], + confirmation: "Stop the requested Windows service?", + }, + restartService: { + script: `param([string]$Name) +Restart-Service -Name $Name`, + allowedCmdlets: ["Restart-Service"], + confirmation: "Restart the requested Windows service?", + }, +} satisfies NamespaceActionDefinitions; + +export const servicesActionHandler = + createPowerShellNamespaceActionHandler( + "powershell.powershell-services", + definitions, + ); diff --git a/ts/packages/agents/powershell/src/namespaces/system/actionHandler.mts b/ts/packages/agents/powershell/src/namespaces/system/actionHandler.mts new file mode 100644 index 0000000000..20530533d8 --- /dev/null +++ b/ts/packages/agents/powershell/src/namespaces/system/actionHandler.mts @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { PowerShellSystemActions } from "./systemActionsSchema.mjs"; +import { + createPowerShellNamespaceActionHandler, + type NamespaceActionDefinitions, +} from "../namespaceActionHandler.mjs"; + +const definitions = { + systemInfo: { + script: `Get-CimInstance Win32_OperatingSystem +Get-CimInstance Win32_ComputerSystem`, + allowedCmdlets: ["Get-CimInstance"], + }, + diskUsage: { + script: `param([string]$DriveLetter) +$drives = Get-PSDrive -PSProvider FileSystem +if ($DriveLetter) { $drives | Where-Object { $_.Name -eq $DriveLetter } } else { $drives }`, + allowedCmdlets: ["Get-PSDrive", "Where-Object"], + }, + hotFixes: { + script: "Get-HotFix", + allowedCmdlets: ["Get-HotFix"], + }, + uptime: { + script: `(Get-Date) - (Get-CimInstance Win32_OperatingSystem).LastBootUpTime`, + allowedCmdlets: ["Get-Date", "Get-CimInstance"], + }, + envVars: { + script: `param([string]$Name) +if ($Name) { Get-Item -LiteralPath "Env:$Name" } else { Get-ChildItem Env: }`, + allowedCmdlets: ["Get-Item", "Get-ChildItem"], + }, +} satisfies NamespaceActionDefinitions; + +export const systemActionHandler = + createPowerShellNamespaceActionHandler( + "powershell.powershell-system", + definitions, + ); diff --git a/ts/packages/agents/powershell/src/schema/scriptActions.mts b/ts/packages/agents/powershell/src/schema/scriptActions.mts index 890854127c..7340b55eb6 100644 --- a/ts/packages/agents/powershell/src/schema/scriptActions.mts +++ b/ts/packages/agents/powershell/src/schema/scriptActions.mts @@ -153,6 +153,23 @@ export type EditPowerShellFlow = { }; }; +// Repair an existing flow and retry the requested operation once +export type RepairAndExecutePowerShellFlow = { + actionName: "repairAndExecutePowerShellFlow"; + parameters: { + // Existing flow to repair + flowName: string; + // Replacement script body + script: string; + // Updated cmdlet whitelist + allowedCmdlets: string[]; + // Updated module whitelist + allowedModules?: string[]; + // JSON string of named parameters for the retry + executionParametersJson?: string; + }; +}; + // Import an existing PowerShell script file as a new PowerShell flow export type ImportPowerShellFlow = { actionName: "importPowerShellFlow"; @@ -174,4 +191,5 @@ export type PowerShellActions = | AddPowerShellFlowPatterns | ReportPowerShellCapabilityOutcome | EditPowerShellFlow + | RepairAndExecutePowerShellFlow | ImportPowerShellFlow; diff --git a/ts/packages/agents/powershell/src/store/powerShellStore.mts b/ts/packages/agents/powershell/src/store/powerShellStore.mts index 0a250f3429..55118b12f3 100644 --- a/ts/packages/agents/powershell/src/store/powerShellStore.mts +++ b/ts/packages/agents/powershell/src/store/powerShellStore.mts @@ -525,6 +525,18 @@ export class PowerShellStore { " };", "};", "", + "// Repair an existing flow and retry once", + "export type RepairAndExecutePowerShellFlow = {", + ' actionName: "repairAndExecutePowerShellFlow";', + " parameters: {", + ` flowName: ${flowNameType};`, + " script: string;", + " allowedCmdlets: string[];", + " allowedModules?: string[];", + " executionParametersJson?: string;", + " };", + "};", + "", "// Import an existing PowerShell script file as a new PowerShell flow", "export type ImportPowerShellFlow = {", ' actionName: "importPowerShellFlow";', @@ -545,6 +557,7 @@ export class PowerShellStore { "AddPowerShellFlowPatterns", "ReportPowerShellCapabilityOutcome", "EditPowerShellFlow", + "RepairAndExecutePowerShellFlow", "ImportPowerShellFlow", ]; diff --git a/ts/packages/agents/powershell/src/types/powerShellAgentContext.mts b/ts/packages/agents/powershell/src/types/powerShellAgentContext.mts new file mode 100644 index 0000000000..34d9be9866 --- /dev/null +++ b/ts/packages/agents/powershell/src/types/powerShellAgentContext.mts @@ -0,0 +1,8 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { PowerShellStore } from "../store/powerShellStore.mjs"; + +export interface PowerShellAgentContext { + store?: PowerShellStore | undefined; +} diff --git a/ts/packages/agents/powershell/src/types/powerShellFailure.mts b/ts/packages/agents/powershell/src/types/powerShellFailure.mts new file mode 100644 index 0000000000..ac8f0d6c38 --- /dev/null +++ b/ts/packages/agents/powershell/src/types/powerShellFailure.mts @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { ActionResultError } from "@typeagent/agent-sdk"; +import type { ScriptExecutionResult } from "../execution/powershellRunner.mjs"; + +export type PowerShellFailureKind = + | "unknownFlow" + | "invalidParameters" + | "scriptFailure" + | "policyDenied" + | "cancelled" + | "partialSideEffects"; + +const retryableFailures = new Set([ + "unknownFlow", + "invalidParameters", + "scriptFailure", +]); + +export function createPowerShellFailure( + kind: PowerShellFailureKind, + error: string, + options?: { + retryable?: boolean; + mayHaveSideEffects?: boolean; + }, +): ActionResultError { + const retryable = options?.retryable ?? retryableFailures.has(kind); + return { + error, + errorCode: `powershell.${kind}`, + retryable, + mayHaveSideEffects: + options?.mayHaveSideEffects ?? kind === "partialSideEffects", + fallbackToReasoning: + retryable && kind !== "cancelled" && kind !== "partialSideEffects", + }; +} + +export function createPowerShellExecutionFailure( + result: ScriptExecutionResult, +): ActionResultError { + if (result.cancelled) { + return createPowerShellFailure( + "cancelled", + "PowerShell execution was cancelled.", + { retryable: false }, + ); + } + const error = result.stderr || `Script exited with code ${result.exitCode}`; + if ( + /denied|not allowed|requires networkAccess|outside allowed|unauthorized/i.test( + error, + ) + ) { + return createPowerShellFailure("policyDenied", error, { + retryable: false, + }); + } + return createPowerShellFailure("scriptFailure", error); +} diff --git a/ts/packages/agents/powershell/test/actionHandler.spec.ts b/ts/packages/agents/powershell/test/actionHandler.spec.ts index 478d70e9e6..ee159ecb29 100644 --- a/ts/packages/agents/powershell/test/actionHandler.spec.ts +++ b/ts/packages/agents/powershell/test/actionHandler.spec.ts @@ -8,10 +8,16 @@ import type { TokenCachePersistence, } from "@typeagent/agent-sdk"; import { jest } from "@jest/globals"; -import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { readFileSync } from "node:fs"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { instantiate } from "../src/actionHandler.mjs"; +import { executeScript } from "../src/execution/powershellRunner.mjs"; +import { + getRegisteredNamespaceActions, + hasNamespaceAction, +} from "../src/namespaces/actionHandlerRegistry.mjs"; const itOnWindows = process.platform === "win32" ? it : it.skip; @@ -69,6 +75,7 @@ class MemoryStorage implements Storage { function createSessionContext( storage: Storage, reloadAgentSchema: () => Promise = jest.fn(async () => {}), + popupQuestion: SessionContext["popupQuestion"] = async () => 1, ): SessionContext { return { agentContext: {}, @@ -77,7 +84,7 @@ function createSessionContext( sessionContextId: "powershell-action-handler-test", notify: jest.fn(), beginAgentThread: jest.fn(), - popupQuestion: jest.fn(), + popupQuestion, toggleTransientAgent: jest.fn(), addDynamicAgent: jest.fn(), removeDynamicAgent: jest.fn(), @@ -93,6 +100,7 @@ function createSessionContext( function createActionContext( sessionContext: SessionContext, + abortSignal?: AbortSignal, ): ActionContext { return { streamingContext: undefined, @@ -104,14 +112,23 @@ function createActionContext( takeAction: jest.fn(), }, sessionContext, + abortSignal, isFromReasoningLoop: true, queueToggleTransientAgent: async () => {}, }; } -async function createAgentHarness(reloadAgentSchema?: () => Promise) { - const storage = new MemoryStorage(); - const sessionContext = createSessionContext(storage, reloadAgentSchema); +async function createAgentHarness( + reloadAgentSchema?: () => Promise, + abortSignal?: AbortSignal, + storage = new MemoryStorage(), + popupQuestion?: SessionContext["popupQuestion"], +) { + const sessionContext = createSessionContext( + storage, + reloadAgentSchema, + popupQuestion, + ); const agent = instantiate(); await agent.initializeAgentContext?.(); await agent.updateAgentContext?.(true, sessionContext, "powershell"); @@ -119,7 +136,7 @@ async function createAgentHarness(reloadAgentSchema?: () => Promise) { agent, storage, sessionContext, - context: createActionContext(sessionContext), + context: createActionContext(sessionContext, abortSignal), }; } @@ -147,9 +164,172 @@ describe("createAndExecutePowerShellFlow", () => { error: expect.stringContaining( "Unknown PowerShell flow 'portListeners'", ), + errorCode: "powershell.unknownFlow", + retryable: true, }); }); + }); + + describe("static namespace coverage", () => { + it("registers exactly the namespaces declared by the manifest", () => { + const manifest = JSON.parse( + readFileSync( + join(process.cwd(), "src", "manifest.json"), + "utf8", + ), + ) as { subActionManifests: Record }; + const manifestSchemas = Object.keys(manifest.subActionManifests) + .map((name) => `powershell.${name}`) + .sort(); + const registeredSchemas = [ + ...getRegisteredNamespaceActions().keys(), + ].sort(); + + expect(registeredSchemas).toEqual(manifestSchemas); + expect( + hasNamespaceAction( + "powershell.powershell-network", + "portListeners", + ), + ).toBe(true); + }); + + itOnWindows("executes a read-only system action", async () => { + const { agent, context } = await createAgentHarness(); + + const result = await agent.executeAction?.( + { + schemaName: "powershell.powershell-system", + actionName: "envVars", + parameters: { name: "TEMP" }, + }, + context, + ); + + expect(result).not.toHaveProperty("error"); + }); + + itOnWindows.each([ + ["powershell.powershell-processes", "listProcesses", { topN: 1 }], + ["powershell.powershell-services", "listServices", {}], + ])( + "executes a read-only %s action", + async (schemaName, actionName, parameters) => { + const { agent, context } = await createAgentHarness(); + + const result = await agent.executeAction?.( + { schemaName, actionName, parameters }, + context, + ); + + expect(result).not.toHaveProperty("error"); + }, + ); + + itOnWindows("executes file, data, and archive actions", async () => { + const directory = await mkdtemp( + join(tmpdir(), "typeagent-powershell-static-"), + ); + const textPath = join(directory, "sample.txt"); + const jsonPath = join(directory, "sample.json"); + const archivePath = join(directory, "sample.zip"); + const extractPath = join(directory, "extracted"); + try { + await writeFile(textPath, "sample text"); + await writeFile(jsonPath, JSON.stringify({ value: "sample" })); + const approve = jest.fn(async () => 0); + const { agent, context } = await createAgentHarness( + undefined, + undefined, + undefined, + approve, + ); + + const readText = await agent.executeAction?.( + { + schemaName: "powershell.powershell-files", + actionName: "readFile", + parameters: { path: textPath }, + }, + context, + ); + const readJson = await agent.executeAction?.( + { + schemaName: "powershell.powershell-data", + actionName: "readJson", + parameters: { path: jsonPath }, + }, + context, + ); + const compress = await agent.executeAction?.( + { + schemaName: "powershell.powershell-archives", + actionName: "compress", + parameters: { + sourcePath: textPath, + destinationPath: archivePath, + }, + }, + context, + ); + const expand = await agent.executeAction?.( + { + schemaName: "powershell.powershell-archives", + actionName: "expand", + parameters: { + archivePath, + destinationPath: extractPath, + }, + }, + context, + ); + + expect(readText).not.toHaveProperty("error"); + expect(readJson).not.toHaveProperty("error"); + expect(compress).not.toHaveProperty("error"); + expect(expand).not.toHaveProperty("error"); + expect((await readFile(archivePath)).length).toBeGreaterThan(0); + expect( + await readFile(join(extractPath, "sample.txt"), "utf8"), + ).toBe("sample text"); + expect(approve).toHaveBeenCalledTimes(2); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + + it("denies mutating actions when confirmation is not approved", async () => { + const directory = await mkdtemp( + join(tmpdir(), "typeagent-powershell-denied-"), + ); + const outputPath = join(directory, "denied.txt"); + try { + const { agent, context } = await createAgentHarness(); + + const result = await agent.executeAction?.( + { + schemaName: "powershell.powershell-files", + actionName: "writeFile", + parameters: { + path: outputPath, + content: "should not be written", + }, + }, + context, + ); + + expect(result).toMatchObject({ + errorCode: "powershell.policyDenied", + retryable: false, + }); + await expect(readFile(outputPath, "utf8")).rejects.toThrow(); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + }); + describe("static network actions", () => { itOnWindows( "executes portListeners without requiring a dynamic flow", async () => { @@ -163,7 +343,6 @@ describe("createAndExecutePowerShellFlow", () => { }, context, ); - expect(result).not.toHaveProperty("error"); }, ); @@ -279,6 +458,8 @@ describe("createAndExecutePowerShellFlow", () => { expect(reloadAgentSchema).toHaveBeenCalledTimes(1); expect(result).toMatchObject({ error: expect.stringContaining("could not be activated"), + errorCode: "powershell.partialSideEffects", + mayHaveSideEffects: true, }); expect(await storage.list("pending")).toEqual([]); expect(await storage.exists("flows/reloadFailure.flow.json")).toBe( @@ -286,4 +467,259 @@ describe("createAndExecutePowerShellFlow", () => { ); }, ); + + itOnWindows("cancels execution and removes the pending draft", async () => { + const controller = new AbortController(); + const { agent, storage, context } = await createAgentHarness( + undefined, + controller.signal, + ); + + const execution = agent.executeAction?.( + { + schemaName: "powershell", + actionName: "createAndExecutePowerShellFlow", + parameters: { + actionName: "cancelledDraft", + description: "A flow cancelled during execution", + script: "Start-Sleep -Seconds 30", + allowedCmdlets: ["Start-Sleep"], + executionParametersJson: "{}", + }, + }, + context, + ); + setTimeout(() => controller.abort(), 250); + + await expect(execution).rejects.toMatchObject({ + name: "AbortError", + }); + expect(await storage.list("pending")).toEqual([]); + expect(await storage.exists("flows/cancelledDraft.flow.json")).toBe( + false, + ); + }); + + itOnWindows( + "deduplicates concurrent creation and reuses the winning flow", + async () => { + const directory = await mkdtemp( + join(tmpdir(), "typeagent-powershell-concurrent-"), + ); + const firstPath = join(directory, "first.txt"); + const secondPath = join(directory, "second.txt"); + try { + const { agent, storage, context } = await createAgentHarness(); + const create = (outputPath: string) => + agent.executeAction?.( + { + schemaName: "powershell", + actionName: "createAndExecutePowerShellFlow", + parameters: { + actionName: "concurrentFlow", + description: "Record a concurrent execution", + script: "param([string]$Path)\nSet-Content -LiteralPath $Path -Value 'run'", + scriptParameters: [ + { + name: "Path", + type: "path", + required: true, + description: "Output file", + }, + ], + allowedCmdlets: ["Set-Content"], + executionParametersJson: JSON.stringify({ + Path: outputPath, + }), + }, + }, + context, + ); + + const [first, second] = await Promise.all([ + create(firstPath), + create(secondPath), + ]); + + expect(first).not.toHaveProperty("error"); + expect(second).not.toHaveProperty("error"); + expect(await storage.list("pending")).toEqual([]); + expect( + await storage.exists("flows/concurrentFlow.flow.json"), + ).toBe(true); + expect(await readFile(firstPath, "utf8")).toContain("run"); + expect(await readFile(secondPath, "utf8")).toContain("run"); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }, + ); + + itOnWindows( + "repairs a stale flow once and keeps the repaired script", + async () => { + const directory = await mkdtemp( + join(tmpdir(), "typeagent-powershell-repair-"), + ); + const outputPath = join(directory, "repair.txt"); + try { + const { agent, context } = await createAgentHarness(); + await agent.executeAction?.( + { + schemaName: "powershell", + actionName: "createAndExecutePowerShellFlow", + parameters: { + actionName: "repairableFlow", + description: "A repairable flow", + script: "param([string]$Path)\nSet-Content -LiteralPath $Path -Value 'original'", + scriptParameters: [ + { + name: "Path", + type: "path", + required: true, + description: "Output file", + }, + ], + allowedCmdlets: ["Set-Content"], + executionParametersJson: JSON.stringify({ + Path: outputPath, + }), + }, + }, + context, + ); + + const repaired = await agent.executeAction?.( + { + schemaName: "powershell", + actionName: "repairAndExecutePowerShellFlow", + parameters: { + flowName: "repairableFlow", + script: "param([string]$Path)\nSet-Content -LiteralPath $Path -Value 'repaired'", + allowedCmdlets: ["Set-Content"], + executionParametersJson: JSON.stringify({ + Path: outputPath, + }), + }, + }, + context, + ); + const secondRepair = await agent.executeAction?.( + { + schemaName: "powershell", + actionName: "repairAndExecutePowerShellFlow", + parameters: { + flowName: "repairableFlow", + script: "throw 'second repair'", + allowedCmdlets: [], + executionParametersJson: JSON.stringify({ + Path: outputPath, + }), + }, + }, + context, + ); + + expect(repaired).not.toHaveProperty("error"); + expect((await readFile(outputPath, "utf8")).trim()).toBe( + "repaired", + ); + expect(secondRepair).toMatchObject({ + errorCode: "powershell.policyDenied", + retryable: false, + }); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }, + ); + + itOnWindows("reloads a promoted flow in a new agent instance", async () => { + const storage = new MemoryStorage(); + const first = await createAgentHarness(undefined, undefined, storage); + const created = await first.agent.executeAction?.( + { + schemaName: "powershell", + actionName: "createAndExecutePowerShellFlow", + parameters: { + actionName: "persistentFlow", + description: "A flow persisted across agent instances", + script: "Write-Output 'persisted'", + allowedCmdlets: ["Write-Output"], + executionParametersJson: "{}", + }, + }, + first.context, + ); + expect(created).not.toHaveProperty("error"); + + const second = await createAgentHarness(undefined, undefined, storage); + const reused = await second.agent.executeAction?.( + { + schemaName: "powershell", + actionName: "persistentFlow", + parameters: {}, + }, + second.context, + ); + + expect(reused).not.toHaveProperty("error"); + }); + + itOnWindows( + "accepts a short path alias for an allowed long path", + async () => { + const result = await executeScript({ + script: "param([string]$Path)\nGet-Item -LiteralPath $Path", + parameters: { Path: "C:\\PROGRA~1" }, + sandbox: { + allowedCmdlets: ["Get-Item"], + allowedPaths: ["C:\\Program Files"], + allowedModules: [], + maxExecutionTime: 10, + networkAccess: false, + }, + }); + + expect(result.success).toBe(true); + expect(result.stderr).toBe(""); + }, + ); + + itOnWindows( + "blocks writes to non-existent paths outside the sandbox", + async () => { + const directory = await mkdtemp( + join(tmpdir(), "typeagent-powershell-path-policy-"), + ); + const allowedDirectory = join(directory, "allowed"); + const blockedPath = join( + directory, + "allowed-sibling", + "blocked.txt", + ); + try { + await mkdir(allowedDirectory); + + const result = await executeScript({ + script: `param([string]$Path) +Set-Content -LiteralPath $Path -Value "blocked"`, + parameters: { Path: blockedPath }, + sandbox: { + allowedCmdlets: ["Set-Content"], + allowedPaths: [allowedDirectory], + allowedModules: [], + maxExecutionTime: 10, + networkAccess: false, + }, + }); + + expect(result.success).toBe(false); + expect(result.stderr).toMatch(/Path access denied/i); + await expect(readFile(blockedPath, "utf8")).rejects.toThrow(); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }, + ); }); diff --git a/ts/packages/agents/powershell/test/powerShellStore.spec.ts b/ts/packages/agents/powershell/test/powerShellStore.spec.ts index ca323bded1..dca8dcf673 100644 --- a/ts/packages/agents/powershell/test/powerShellStore.spec.ts +++ b/ts/packages/agents/powershell/test/powerShellStore.spec.ts @@ -141,5 +141,6 @@ describe("PowerShellStore capability lifecycle", () => { expect(schema).toContain("createAndExecutePowerShellFlow"); expect(schema).toContain("addPowerShellFlowPatterns"); expect(schema).toContain("reportPowerShellCapabilityOutcome"); + expect(schema).toContain("repairAndExecutePowerShellFlow"); }); }); diff --git a/ts/packages/copilot-plugin/hooks.json b/ts/packages/copilot-plugin/hooks.json index 6dac12c30d..3ee7c5cdc6 100644 --- a/ts/packages/copilot-plugin/hooks.json +++ b/ts/packages/copilot-plugin/hooks.json @@ -5,7 +5,7 @@ { "type": "command", "command": "node ${PLUGIN_ROOT}/dist/hooks/hook-router.js", - "timeout": 30000 + "timeout": 300000 } ], "agentStop": [ diff --git a/ts/packages/copilot-plugin/src/hooks/hook-dev-actions.ts b/ts/packages/copilot-plugin/src/hooks/hook-dev-actions.ts index fa136d9eb3..d2deea8e91 100644 --- a/ts/packages/copilot-plugin/src/hooks/hook-dev-actions.ts +++ b/ts/packages/copilot-plugin/src/hooks/hook-dev-actions.ts @@ -2,6 +2,7 @@ // Licensed under the MIT License. import type { Dispatcher } from "@typeagent/agent-server-client"; +import { randomUUID } from "node:crypto"; import type { CommandResult, ProcessCommandOptions, @@ -21,18 +22,26 @@ import type { HookInput, HookOutput } from "./types.js"; export type DevActionDependencies = { connectToTypeAgent: typeof connectToTypeAgent; emitProgress: typeof emitProgress; + platform?: NodeJS.Platform; }; const defaultDependencies: DevActionDependencies = { connectToTypeAgent, emitProgress, + platform: process.platform, }; +const unsupportedPlatformMessage = + "TypeAgent PowerShell recording is supported only on Windows. Run the request on a Windows host with the PowerShell agent enabled."; +const unavailablePowerShellMessage = + "TypeAgent could not record this PowerShell flow because the PowerShell schema is unavailable. Enable the PowerShell agent and retry."; + export function getDevActionCommandOptions( prompt: string, ): ProcessCommandOptions { if (parseRecordingDirective(prompt) !== undefined) { return { + activeSchemaFamilies: ["powershell"], noReasoning: false, reasoningProfile: "powershellFlowRecording", }; @@ -64,7 +73,20 @@ function toHandledOutput( export async function handleDevActions( input: HookInput, dependencies: DevActionDependencies = defaultDependencies, + abortSignal?: AbortSignal, ): Promise { + const isRecordingDirective = + parseRecordingDirective(input.prompt) !== undefined; + if ((dependencies.platform ?? process.platform) !== "win32") { + return isRecordingDirective + ? { + handled: true, + responseContent: unsupportedPlatformMessage, + handledBy: "typeagent", + } + : {}; + } + dependencies.emitProgress("Checking TypeAgent development actions...", { temporary: true, }); @@ -107,19 +129,53 @@ export async function handleDevActions( }); let dispatcher: Dispatcher | null = null; - let submitted = false; + let submissionStarted = false; + let requestId: string | undefined; + const clientRequestId = `copilot-dev-${input.sessionId}-${randomUUID()}`; + let earlyCancellation: Promise | undefined; + const cancelAcceptedRequest = () => { + if (!dispatcher) { + return; + } + if (requestId) { + earlyCancellation = dispatcher + .cancelCommand(requestId) + .then(() => undefined); + } else { + try { + dispatcher.cancelCommandByClientId(clientRequestId); + } catch (error) { + console.error("TypeAgent dev mode cancellation error:", error); + } + earlyCancellation = Promise.resolve(); + } + void earlyCancellation.catch((error) => { + console.error("TypeAgent dev mode cancellation error:", error); + }); + }; + abortSignal?.addEventListener("abort", cancelAcceptedRequest, { + once: true, + }); + try { + abortSignal?.throwIfAborted(); dispatcher = await dependencies.connectToTypeAgent(clientIO); + abortSignal?.throwIfAborted(); + submissionStarted = true; const submitResult = await dispatcher.submitCommand( input.prompt, undefined, getDevActionCommandOptions(input.prompt), + clientRequestId, ); if (!submitResult.ok) { return {}; } - submitted = true; + requestId = submitResult.entry.requestId; + if (abortSignal?.aborted) { + await dispatcher.cancelCommand(requestId); + } const result = await submitResult.entry.completion; if (!result) { return { @@ -142,15 +198,32 @@ export async function handleDevActions( } if (result.disposition.status === "notHandled") { + if ( + isRecordingDirective && + result.disposition.reason === "noActiveSchema" + ) { + return { + handled: true, + responseContent: unavailablePowerShellMessage, + handledBy: "typeagent", + }; + } return {}; } return toHandledOutput(result, responseCollector.messages); } catch (error) { console.error("TypeAgent dev mode error:", error); - if (!submitted) { + if (!submissionStarted) { return {}; } + if (abortSignal?.aborted) { + return { + handled: true, + responseContent: "TypeAgent request was cancelled.", + handledBy: "typeagent", + }; + } return { handled: true, responseContent: `TypeAgent could not finish the submitted development action: ${ @@ -159,6 +232,8 @@ export async function handleDevActions( handledBy: "typeagent", }; } finally { + abortSignal?.removeEventListener("abort", cancelAcceptedRequest); + await earlyCancellation?.catch(() => {}); if (dispatcher) { await dispatcher.close(); } diff --git a/ts/packages/copilot-plugin/src/hooks/hook-router.ts b/ts/packages/copilot-plugin/src/hooks/hook-router.ts index bcbc65c448..13e295d5ec 100644 --- a/ts/packages/copilot-plugin/src/hooks/hook-router.ts +++ b/ts/packages/copilot-plugin/src/hooks/hook-router.ts @@ -166,46 +166,60 @@ function handleSlashCommand( } async function main(): Promise { - let inputData = ""; - process.stdin.setEncoding("utf8"); + const abortController = new AbortController(); + const abortRequest = () => abortController.abort(); + process.once("SIGINT", abortRequest); + process.once("SIGTERM", abortRequest); - for await (const chunk of process.stdin) { - inputData += chunk; - } - - let input: HookInput; try { - input = JSON.parse(inputData); - } catch { - console.error("Failed to parse hook input"); - process.exit(1); - } + let inputData = ""; + process.stdin.setEncoding("utf8"); - // Check for slash commands first - const slashResult = await handleSlashCommand(input.prompt); - if (slashResult) { - console.log(JSON.stringify(slashResult)); - emitDemoStateForOutput(input, slashResult, "direct"); - return; - } + for await (const chunk of process.stdin) { + inputData += chunk; + } - // Route based on current mode - const mode = getMode(); - let output: HookOutput; - - if (mode === "bypass") { - // Bypass mode: return empty to fall through to other handlers - output = {}; - } else if (mode === "mcp") { - output = handleMcpRedirect(input); - } else if (mode === "dev") { - output = await handleDevActions(input); - } else { - output = await handleDirect(input); - } + let input: HookInput; + try { + input = JSON.parse(inputData); + } catch { + console.error("Failed to parse hook input"); + process.exit(1); + } - console.log(JSON.stringify(output)); - emitDemoStateForOutput(input, output, mode); + // Check for slash commands first + const slashResult = await handleSlashCommand(input.prompt); + if (slashResult) { + console.log(JSON.stringify(slashResult)); + emitDemoStateForOutput(input, slashResult, "direct"); + return; + } + + // Route based on current mode + const mode = getMode(); + let output: HookOutput; + + if (mode === "bypass") { + // Bypass mode: return empty to fall through to other handlers + output = {}; + } else if (mode === "mcp") { + output = handleMcpRedirect(input); + } else if (mode === "dev") { + output = await handleDevActions( + input, + undefined, + abortController.signal, + ); + } else { + output = await handleDirect(input); + } + + console.log(JSON.stringify(output)); + emitDemoStateForOutput(input, output, mode); + } finally { + process.removeListener("SIGINT", abortRequest); + process.removeListener("SIGTERM", abortRequest); + } } /** diff --git a/ts/packages/copilot-plugin/src/shared/typeagent-client.ts b/ts/packages/copilot-plugin/src/shared/typeagent-client.ts index 95dc05347f..cc3155fb94 100644 --- a/ts/packages/copilot-plugin/src/shared/typeagent-client.ts +++ b/ts/packages/copilot-plugin/src/shared/typeagent-client.ts @@ -43,13 +43,6 @@ export function createClientIO(callbacks: DisplayCallbacks): ClientIO { }, appendDiagnosticData(): void {}, setDynamicDisplay(): void {}, - async askYesNo( - _requestId: RequestId, - _message: string, - defaultValue?: boolean, - ): Promise { - return defaultValue ?? true; - }, async proposeAction( _requestId: RequestId, _actionTemplates: TemplateEditConfig, @@ -57,14 +50,6 @@ export function createClientIO(callbacks: DisplayCallbacks): ClientIO { ): Promise { return undefined; }, - async popupQuestion( - _message: string, - _choices: string[], - defaultId: number | undefined, - _source: string, - ): Promise { - return defaultId ?? 0; - }, notify(): void {}, async openLocalView(): Promise {}, async closeLocalView(): Promise {}, @@ -72,8 +57,14 @@ export function createClientIO(callbacks: DisplayCallbacks): ClientIO { requestForm(): void {}, takeAction(): void {}, shutdown(): void {}, - async question(): Promise { - return 0; + async question( + _requestId: RequestId | undefined, + _message: string, + choices: string[], + defaultId?: number, + _source?: string, + ): Promise { + return defaultId ?? Math.max(choices.length - 1, 0); }, requestInteraction(): void {}, interactionResolved(): void {}, diff --git a/ts/packages/copilot-plugin/test/hookDevActions.spec.ts b/ts/packages/copilot-plugin/test/hookDevActions.spec.ts index b5d066f287..8e294a96b2 100644 --- a/ts/packages/copilot-plugin/test/hookDevActions.spec.ts +++ b/ts/packages/copilot-plugin/test/hookDevActions.spec.ts @@ -12,6 +12,7 @@ import { handleDevActions, type DevActionDependencies, } from "../src/hooks/hook-dev-actions.js"; +import { createClientIO } from "../src/shared/typeagent-client.js"; const input = { sessionId: "test-session", @@ -25,25 +26,37 @@ function createDependencies( ): { dependencies: DevActionDependencies; submitCommand: jest.Mock; + cancelCommand: jest.Mock; + cancelCommandByClientId: jest.Mock; close: jest.Mock; } { const completion = result instanceof Promise ? result : Promise.resolve(result); const submitCommand = jest.fn(async () => ({ ok: true, - entry: { completion }, + entry: { requestId: "request-1", completion }, })); + const cancelCommand = jest.fn(async () => ({ + kind: "running", + requestId: "request-1", + })); + const cancelCommandByClientId = jest.fn(); const close = jest.fn(async () => {}); const dispatcher = { submitCommand, + cancelCommand, + cancelCommandByClientId, close, } as unknown as Dispatcher; return { dependencies: { connectToTypeAgent: jest.fn(async () => dispatcher), emitProgress: jest.fn(), + platform: "win32", }, submitCommand, + cancelCommand, + cancelCommandByClientId, close, }; } @@ -61,6 +74,7 @@ describe("Copilot dev actions hook", () => { expect( getDevActionCommandOptions("learn: show running processes"), ).toEqual({ + activeSchemaFamilies: ["powershell"], noReasoning: false, reasoningProfile: "powershellFlowRecording", }); @@ -108,6 +122,7 @@ describe("Copilot dev actions hook", () => { throw new Error("server unavailable"); }), emitProgress: jest.fn(), + platform: "win32", }; await expect(handleDevActions(input, dependencies)).resolves.toEqual( @@ -132,4 +147,111 @@ describe("Copilot dev actions hook", () => { }); consoleError.mockRestore(); }); + + it("falls through for ordinary prompts on non-Windows platforms", async () => { + const { dependencies } = createDependencies({ + disposition: { + status: "handled", + path: "action", + schemas: ["powershell"], + }, + }); + dependencies.platform = "linux"; + + await expect(handleDevActions(input, dependencies)).resolves.toEqual( + {}, + ); + expect(dependencies.connectToTypeAgent).not.toHaveBeenCalled(); + }); + + it("reports unsupported explicit recording on non-Windows platforms", async () => { + const { dependencies } = createDependencies(undefined); + dependencies.platform = "darwin"; + + await expect( + handleDevActions( + { ...input, prompt: "learn: show running processes" }, + dependencies, + ), + ).resolves.toEqual({ + handled: true, + responseContent: + "TypeAgent PowerShell recording is supported only on Windows. Run the request on a Windows host with the PowerShell agent enabled.", + handledBy: "typeagent", + }); + }); + + it("reports unavailable PowerShell for explicit recording", async () => { + const { dependencies } = createDependencies({ + disposition: { + status: "notHandled", + reason: "noActiveSchema", + }, + }); + + await expect( + handleDevActions( + { ...input, prompt: "record: show running processes" }, + dependencies, + ), + ).resolves.toEqual({ + handled: true, + responseContent: + "TypeAgent could not record this PowerShell flow because the PowerShell schema is unavailable. Enable the PowerShell agent and retry.", + handledBy: "typeagent", + }); + }); + + it("cancels an accepted request when the hook is aborted", async () => { + let resolveCompletion: + | ((result: CommandResult | undefined) => void) + | undefined; + const completion = new Promise((resolve) => { + resolveCompletion = resolve; + }); + const { dependencies, cancelCommand } = createDependencies(completion); + cancelCommand.mockImplementation(async () => { + resolveCompletion?.({ cancelled: true }); + return { kind: "running", requestId: "request-1" }; + }); + const controller = new AbortController(); + + const handled = handleDevActions( + input, + dependencies, + controller.signal, + ); + await new Promise((resolve) => setImmediate(resolve)); + controller.abort(); + + await expect(handled).resolves.toEqual({ + handled: true, + responseContent: "TypeAgent request was cancelled.", + handledBy: "typeagent", + }); + expect(cancelCommand).toHaveBeenCalledWith("request-1"); + }); + + it("defaults unattended interactions to denial", async () => { + const clientIO = createClientIO({}); + + await expect( + clientIO.question( + undefined, + "Allow action?", + ["Run", "Cancel"], + undefined, + "powershell", + ), + ).resolves.toBe(1); + await expect( + clientIO.question( + undefined, + "Allow action?", + ["Run", "Cancel"], + 1, + "powershell", + ), + ).resolves.toBe(1); + }); }); diff --git a/ts/packages/dispatcher/dispatcher/src/reasoning/reasoningProfile.ts b/ts/packages/dispatcher/dispatcher/src/reasoning/reasoningProfile.ts index d6d1139a8a..5b6aa29015 100644 --- a/ts/packages/dispatcher/dispatcher/src/reasoning/reasoningProfile.ts +++ b/ts/packages/dispatcher/dispatcher/src/reasoning/reasoningProfile.ts @@ -34,6 +34,7 @@ function getPowerShellCapabilityFallbackGuidance(): string { "Decide whether the user's request can be safely completed as a reusable PowerShell flow.", "Use discover_actions for the powershell schema and listPowerShellFlows before creating anything.", "Prefer an existing executable action or flow. If an existing flow covers the task but misses this phrasing, add validated patterns with addPowerShellFlowPatterns, then execute the existing flow once.", + "If an existing flow fails with errorCode powershell.scriptFailure, repair that same flow with repairAndExecutePowerShellFlow at most once. Do not repair policyDenied, cancelled, or partialSideEffects failures.", "If no equivalent exists, use createAndExecutePowerShellFlow. It executes the draft once and promotes it only after success. Do not execute the promoted flow again.", "Do not use shell, Bash, TaskFlow, or WebFlow as substitutes.", "You MUST finish by calling reportPowerShellCapabilityOutcome exactly once.",