@rover-studio/answer-me 0.1.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/answerme-toolkit.mjs +6 -0
- package/distribution/npm/migrations.json +605 -0
- package/distribution/npm/package-manifest.json +192 -0
- package/distribution/npm/skills/answerme/SKILL.md +94 -0
- package/distribution/npm/skills/answerme/agents/openai.yaml +4 -0
- package/distribution/npm/skills/answerme/references/api.md +135 -0
- package/distribution/npm/skills/answerme/references/creator-credential-deployment.md +19 -0
- package/distribution/npm/skills/answerme/references/creator-credential-recovery.md +24 -0
- package/distribution/npm/skills/answerme/references/errors.md +44 -0
- package/distribution/npm/skills/answerme/references/handoff.md +46 -0
- package/distribution/npm/skills/answerme/references/install-self-test.md +28 -0
- package/distribution/npm/skills/answerme/references/result-token-store.md +50 -0
- package/distribution/npm/skills/answerme/references/templates.md +139 -0
- package/distribution/npm/skills/answerme/scripts/answerme-api-base-url.ps1 +40 -0
- package/distribution/npm/skills/answerme/scripts/create-answerme.ps1 +1892 -0
- package/distribution/npm/skills/answerme/scripts/creator-credential-store.windows.ps1 +503 -0
- package/distribution/npm/skills/answerme/scripts/deploy-answerme-creator-credential.ps1 +447 -0
- package/distribution/npm/skills/answerme/scripts/enroll-answerme-creator.ps1 +764 -0
- package/distribution/npm/skills/answerme/scripts/open-answerme-page.windows.ps1 +272 -0
- package/distribution/npm/skills/answerme/scripts/remove-answerme-result-token.ps1 +63 -0
- package/distribution/npm/skills/answerme/scripts/result-token-store.windows.ps1 +261 -0
- package/distribution/npm/skills/answerme/scripts/test-answerme-installation.ps1 +498 -0
- package/distribution/npm/skills/answerme/scripts/wait-answerme-result.ps1 +908 -0
- package/distribution/npm/skills/answerme/scripts/windows-crypto.ps1 +57 -0
- package/distribution/npm/skills/answerme/scripts/windows-http.ps1 +45 -0
- package/distribution/npm/skills/answerme/scripts/windows-process-start-info.ps1 +76 -0
- package/distribution/npm/skills/ask-when-needed/SKILL.md +164 -0
- package/distribution/npm/skills/ask-when-needed/agents/openai.yaml +4 -0
- package/distribution/npm/skills/ask-when-needed/references/interview-strategies.md +43 -0
- package/lib/npm-cli/commands.mjs +247 -0
- package/lib/npm-cli/constants.mjs +51 -0
- package/lib/npm-cli/errors.mjs +15 -0
- package/lib/npm-cli/filesystem.mjs +193 -0
- package/lib/npm-cli/host-discovery.mjs +404 -0
- package/lib/npm-cli/main.mjs +42 -0
- package/lib/npm-cli/package-integrity.mjs +212 -0
- package/lib/npm-cli/transaction.mjs +375 -0
- package/lib/npm-cli/usage-validation.mjs +349 -0
- package/package.json +17 -0
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
[CmdletBinding()]
|
|
2
|
+
param(
|
|
3
|
+
[Parameter(Mandatory = $true)]
|
|
4
|
+
[string]$Url,
|
|
5
|
+
[string]$WindowTitleContains = '',
|
|
6
|
+
[ValidateRange(1, 30)]
|
|
7
|
+
[int]$WaitSeconds = 10,
|
|
8
|
+
[ValidateRange(1, 10)]
|
|
9
|
+
[int]$ForegroundAttempts = 4,
|
|
10
|
+
[ValidateRange(10, 250)]
|
|
11
|
+
[int]$WindowPollMilliseconds = 25,
|
|
12
|
+
[ValidateRange(5, 100)]
|
|
13
|
+
[int]$ForegroundPollMilliseconds = 10,
|
|
14
|
+
[ValidateRange(10, 500)]
|
|
15
|
+
[int]$ForegroundAttemptMilliseconds = 100,
|
|
16
|
+
[switch]$ValidateOnly
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
$ErrorActionPreference = 'Stop'
|
|
20
|
+
|
|
21
|
+
$processStartInfoSupport = Join-Path $PSScriptRoot 'windows-process-start-info.ps1'
|
|
22
|
+
if (-not (Test-Path -LiteralPath $processStartInfoSupport -PathType Leaf)) {
|
|
23
|
+
throw 'The AnswerMe Windows process argument helper is missing.'
|
|
24
|
+
}
|
|
25
|
+
. $processStartInfoSupport
|
|
26
|
+
|
|
27
|
+
$operationStopwatch = [System.Diagnostics.Stopwatch]::StartNew()
|
|
28
|
+
|
|
29
|
+
function New-PopupFailureResult {
|
|
30
|
+
param(
|
|
31
|
+
[Parameter(Mandatory = $true)][string]$Code,
|
|
32
|
+
[Parameter(Mandatory = $true)][string]$Message
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
return [PSCustomObject]@{
|
|
36
|
+
ok = $false
|
|
37
|
+
status = 'failed'
|
|
38
|
+
code = $Code
|
|
39
|
+
message = $Message
|
|
40
|
+
inputMode = 'url-only'
|
|
41
|
+
computerControlPerformed = $false
|
|
42
|
+
elapsedMilliseconds = $operationStopwatch.ElapsedMilliseconds
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
$parsedUrl = $null
|
|
47
|
+
if (-not [Uri]::TryCreate($Url, [UriKind]::Absolute, [ref]$parsedUrl) -or $parsedUrl.Scheme -notin @('http', 'https')) {
|
|
48
|
+
New-PopupFailureResult -Code 'invalid-url' -Message 'Url must be an absolute HTTP or HTTPS URL.' |
|
|
49
|
+
ConvertTo-Json -Depth 4
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
$edgeCandidates = @(
|
|
54
|
+
(Get-ItemProperty -LiteralPath 'Registry::HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\msedge.exe' -ErrorAction SilentlyContinue).'(default)',
|
|
55
|
+
(Get-ItemProperty -LiteralPath 'Registry::HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\App Paths\msedge.exe' -ErrorAction SilentlyContinue).'(default)',
|
|
56
|
+
'C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe',
|
|
57
|
+
'C:\Program Files\Microsoft\Edge\Application\msedge.exe'
|
|
58
|
+
) | Where-Object { $_ -and (Test-Path -LiteralPath $_ -PathType Leaf) } | Select-Object -Unique
|
|
59
|
+
|
|
60
|
+
if ($edgeCandidates.Count -eq 0) {
|
|
61
|
+
New-PopupFailureResult -Code 'browser-not-found' -Message 'Microsoft Edge executable was not found.' |
|
|
62
|
+
ConvertTo-Json -Depth 4
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
$edgePath = @($edgeCandidates)[0]
|
|
66
|
+
|
|
67
|
+
if ($ValidateOnly) {
|
|
68
|
+
[PSCustomObject]@{
|
|
69
|
+
ok = $true
|
|
70
|
+
status = 'validated'
|
|
71
|
+
inputMode = 'url-only'
|
|
72
|
+
browserFamily = 'edge'
|
|
73
|
+
browserPath = $edgePath
|
|
74
|
+
urlScheme = $parsedUrl.Scheme
|
|
75
|
+
foregroundStrategyVersion = 3
|
|
76
|
+
foregroundAttempts = $ForegroundAttempts
|
|
77
|
+
requiresNewWindowHandle = $true
|
|
78
|
+
computerControlPerformed = $false
|
|
79
|
+
elapsedMilliseconds = $operationStopwatch.ElapsedMilliseconds
|
|
80
|
+
} | ConvertTo-Json -Depth 4
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
if (-not ('AnswerMePageWindowNative' -as [type])) {
|
|
86
|
+
Add-Type -TypeDefinition @'
|
|
87
|
+
using System;
|
|
88
|
+
using System.Collections.Generic;
|
|
89
|
+
using System.Runtime.InteropServices;
|
|
90
|
+
using System.Text;
|
|
91
|
+
|
|
92
|
+
public static class AnswerMePageWindowNative {
|
|
93
|
+
public delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam);
|
|
94
|
+
[DllImport("user32.dll")] public static extern bool EnumWindows(EnumWindowsProc callback, IntPtr lParam);
|
|
95
|
+
[DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr hWnd);
|
|
96
|
+
[DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern int GetWindowText(IntPtr hWnd, StringBuilder text, int count);
|
|
97
|
+
[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId);
|
|
98
|
+
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
|
|
99
|
+
[DllImport("kernel32.dll")] public static extern uint GetCurrentThreadId();
|
|
100
|
+
[DllImport("user32.dll")] public static extern bool AttachThreadInput(uint idAttach, uint idAttachTo, bool attach);
|
|
101
|
+
[DllImport("user32.dll")] public static extern bool ShowWindowAsync(IntPtr hWnd, int command);
|
|
102
|
+
[DllImport("user32.dll")] public static extern bool BringWindowToTop(IntPtr hWnd);
|
|
103
|
+
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd);
|
|
104
|
+
[DllImport("user32.dll")] public static extern IntPtr SetActiveWindow(IntPtr hWnd);
|
|
105
|
+
[DllImport("user32.dll")] public static extern IntPtr SetFocus(IntPtr hWnd);
|
|
106
|
+
[DllImport("user32.dll")] public static extern bool PeekMessage(out NativeMessage message, IntPtr hWnd, uint min, uint max, uint remove);
|
|
107
|
+
|
|
108
|
+
[StructLayout(LayoutKind.Sequential)]
|
|
109
|
+
public struct NativeMessage {
|
|
110
|
+
public IntPtr hWnd;
|
|
111
|
+
public uint message;
|
|
112
|
+
public UIntPtr wParam;
|
|
113
|
+
public IntPtr lParam;
|
|
114
|
+
public uint time;
|
|
115
|
+
public int pointX;
|
|
116
|
+
public int pointY;
|
|
117
|
+
public uint privateValue;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
public static List<WindowInfo> EdgeWindows() {
|
|
121
|
+
var result = new List<WindowInfo>();
|
|
122
|
+
EnumWindows((hWnd, _) => {
|
|
123
|
+
if (!IsWindowVisible(hWnd)) return true;
|
|
124
|
+
uint processId;
|
|
125
|
+
uint threadId = GetWindowThreadProcessId(hWnd, out processId);
|
|
126
|
+
var text = new StringBuilder(512);
|
|
127
|
+
GetWindowText(hWnd, text, text.Capacity);
|
|
128
|
+
try {
|
|
129
|
+
var process = System.Diagnostics.Process.GetProcessById((int)processId);
|
|
130
|
+
if (String.Equals(process.ProcessName, "msedge", StringComparison.OrdinalIgnoreCase)) {
|
|
131
|
+
result.Add(new WindowInfo { Hwnd = hWnd, Title = text.ToString(), ProcessId = processId, ThreadId = threadId });
|
|
132
|
+
}
|
|
133
|
+
} catch { }
|
|
134
|
+
return true;
|
|
135
|
+
}, IntPtr.Zero);
|
|
136
|
+
return result;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
public sealed class WindowInfo {
|
|
140
|
+
public IntPtr Hwnd { get; set; }
|
|
141
|
+
public string Title { get; set; }
|
|
142
|
+
public uint ProcessId { get; set; }
|
|
143
|
+
public uint ThreadId { get; set; }
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
'@
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
$beforeWindows = [AnswerMePageWindowNative]::EdgeWindows()
|
|
150
|
+
$beforeHandles = [System.Collections.Generic.HashSet[long]]::new()
|
|
151
|
+
foreach ($window in $beforeWindows) { [void]$beforeHandles.Add($window.Hwnd.ToInt64()) }
|
|
152
|
+
|
|
153
|
+
$processInfo = [System.Diagnostics.ProcessStartInfo]::new()
|
|
154
|
+
$processInfo.FileName = $edgePath
|
|
155
|
+
$processInfo.UseShellExecute = $false
|
|
156
|
+
$processInfo.CreateNoWindow = $true
|
|
157
|
+
$processInfo.RedirectStandardOutput = $true
|
|
158
|
+
$processInfo.RedirectStandardError = $true
|
|
159
|
+
Set-AnswerMeProcessArguments `
|
|
160
|
+
-StartInfo $processInfo `
|
|
161
|
+
-Arguments @('--new-window', $parsedUrl.AbsoluteUri)
|
|
162
|
+
$startedProcess = [System.Diagnostics.Process]::Start($processInfo)
|
|
163
|
+
if ($startedProcess) {
|
|
164
|
+
$startedProcess.BeginOutputReadLine()
|
|
165
|
+
$startedProcess.BeginErrorReadLine()
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
$deadline = [DateTimeOffset]::UtcNow.AddSeconds($WaitSeconds)
|
|
169
|
+
$target = $null
|
|
170
|
+
while ([DateTimeOffset]::UtcNow -lt $deadline -and -not $target) {
|
|
171
|
+
$windows = [AnswerMePageWindowNative]::EdgeWindows()
|
|
172
|
+
$target = $windows | Where-Object {
|
|
173
|
+
-not $beforeHandles.Contains($_.Hwnd.ToInt64()) -and
|
|
174
|
+
([string]::IsNullOrEmpty($WindowTitleContains) -or $_.Title -like "*$WindowTitleContains*")
|
|
175
|
+
} | Select-Object -First 1
|
|
176
|
+
if (-not $target) {
|
|
177
|
+
Start-Sleep -Milliseconds $WindowPollMilliseconds
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (-not $target) {
|
|
182
|
+
throw "Edge opened but no new visible window matching '$WindowTitleContains' was found."
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
$foregroundBefore = [AnswerMePageWindowNative]::GetForegroundWindow()
|
|
186
|
+
$currentThread = [AnswerMePageWindowNative]::GetCurrentThreadId()
|
|
187
|
+
$message = [AnswerMePageWindowNative+NativeMessage]::new()
|
|
188
|
+
[void][AnswerMePageWindowNative]::PeekMessage([ref]$message, [IntPtr]::Zero, 0, 0, 0)
|
|
189
|
+
|
|
190
|
+
$attemptEvidence = @()
|
|
191
|
+
$foregroundAfter = $foregroundBefore
|
|
192
|
+
for ($attempt = 1; $attempt -le $ForegroundAttempts -and $foregroundAfter -ne $target.Hwnd; $attempt++) {
|
|
193
|
+
$attemptBefore = [AnswerMePageWindowNative]::GetForegroundWindow()
|
|
194
|
+
$foregroundThread = 0
|
|
195
|
+
$foregroundProcess = 0
|
|
196
|
+
if ($attemptBefore -ne [IntPtr]::Zero) {
|
|
197
|
+
$foregroundThread = [AnswerMePageWindowNative]::GetWindowThreadProcessId($attemptBefore, [ref]$foregroundProcess)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
$attachedCurrent = $false
|
|
201
|
+
$attachedForeground = $false
|
|
202
|
+
try {
|
|
203
|
+
if ($currentThread -ne $target.ThreadId) {
|
|
204
|
+
$attachedCurrent = [AnswerMePageWindowNative]::AttachThreadInput($currentThread, $target.ThreadId, $true)
|
|
205
|
+
}
|
|
206
|
+
if ($foregroundThread -ne 0 -and $foregroundThread -ne $target.ThreadId -and $foregroundThread -ne $currentThread) {
|
|
207
|
+
$attachedForeground = [AnswerMePageWindowNative]::AttachThreadInput($foregroundThread, $target.ThreadId, $true)
|
|
208
|
+
}
|
|
209
|
+
$show = [AnswerMePageWindowNative]::ShowWindowAsync($target.Hwnd, 9)
|
|
210
|
+
$bring = [AnswerMePageWindowNative]::BringWindowToTop($target.Hwnd)
|
|
211
|
+
$setForeground = [AnswerMePageWindowNative]::SetForegroundWindow($target.Hwnd)
|
|
212
|
+
$active = [AnswerMePageWindowNative]::SetActiveWindow($target.Hwnd)
|
|
213
|
+
$focus = [AnswerMePageWindowNative]::SetFocus($target.Hwnd)
|
|
214
|
+
}
|
|
215
|
+
finally {
|
|
216
|
+
if ($attachedForeground) { [void][AnswerMePageWindowNative]::AttachThreadInput($foregroundThread, $target.ThreadId, $false) }
|
|
217
|
+
if ($attachedCurrent) { [void][AnswerMePageWindowNative]::AttachThreadInput($currentThread, $target.ThreadId, $false) }
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
$attemptDeadline = [DateTimeOffset]::UtcNow.AddMilliseconds($ForegroundAttemptMilliseconds)
|
|
221
|
+
do {
|
|
222
|
+
$foregroundAfter = [AnswerMePageWindowNative]::GetForegroundWindow()
|
|
223
|
+
if ($foregroundAfter -eq $target.Hwnd) { break }
|
|
224
|
+
Start-Sleep -Milliseconds $ForegroundPollMilliseconds
|
|
225
|
+
} while ([DateTimeOffset]::UtcNow -lt $attemptDeadline)
|
|
226
|
+
$attemptEvidence += [PSCustomObject]@{
|
|
227
|
+
attempt = $attempt
|
|
228
|
+
foregroundBefore = $attemptBefore.ToInt64()
|
|
229
|
+
foregroundAfter = $foregroundAfter.ToInt64()
|
|
230
|
+
attachedCurrent = $attachedCurrent
|
|
231
|
+
attachedForeground = $attachedForeground
|
|
232
|
+
show = $show
|
|
233
|
+
bring = $bring
|
|
234
|
+
setForeground = $setForeground
|
|
235
|
+
active = $active.ToInt64()
|
|
236
|
+
focus = $focus.ToInt64()
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
$ok = $foregroundAfter -eq $target.Hwnd
|
|
241
|
+
[PSCustomObject]@{
|
|
242
|
+
ok = $ok
|
|
243
|
+
status = if ($ok) { 'foreground-verified' } else { 'failed' }
|
|
244
|
+
code = if ($ok) { $null } else { 'foreground-verification-failed' }
|
|
245
|
+
inputMode = 'url-only'
|
|
246
|
+
browserFamily = 'edge'
|
|
247
|
+
targetHwnd = $target.Hwnd.ToInt64()
|
|
248
|
+
targetTitle = $target.Title
|
|
249
|
+
targetProcessId = $target.ProcessId
|
|
250
|
+
startedProcessId = if ($startedProcess) { $startedProcess.Id } else { $null }
|
|
251
|
+
foregroundBefore = $foregroundBefore.ToInt64()
|
|
252
|
+
foregroundAfter = $foregroundAfter.ToInt64()
|
|
253
|
+
foregroundStrategyVersion = 3
|
|
254
|
+
requiresNewWindowHandle = $true
|
|
255
|
+
computerControlPerformed = $true
|
|
256
|
+
elapsedMilliseconds = $operationStopwatch.ElapsedMilliseconds
|
|
257
|
+
attempts = $attemptEvidence
|
|
258
|
+
} | ConvertTo-Json -Depth 8
|
|
259
|
+
|
|
260
|
+
}
|
|
261
|
+
catch {
|
|
262
|
+
[PSCustomObject]@{
|
|
263
|
+
ok = $false
|
|
264
|
+
status = 'failed'
|
|
265
|
+
code = 'open-or-foreground-failed'
|
|
266
|
+
message = $_.Exception.Message
|
|
267
|
+
inputMode = 'url-only'
|
|
268
|
+
browserFamily = 'edge'
|
|
269
|
+
computerControlPerformed = $true
|
|
270
|
+
elapsedMilliseconds = $operationStopwatch.ElapsedMilliseconds
|
|
271
|
+
} | ConvertTo-Json -Depth 4
|
|
272
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
[CmdletBinding()]
|
|
2
|
+
param(
|
|
3
|
+
[Parameter(Mandatory = $true)]
|
|
4
|
+
[string]$InteractionId,
|
|
5
|
+
[string]$ResultTokenStoreRoot = $env:ANSWERME_RESULT_TOKEN_STORE_ROOT,
|
|
6
|
+
[switch]$AllowNonDefaultResultTokenStore
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
$ErrorActionPreference = 'Stop'
|
|
10
|
+
|
|
11
|
+
function Write-AnswerMeCleanupFailure {
|
|
12
|
+
param(
|
|
13
|
+
[Parameter(Mandatory = $true)][string]$Code,
|
|
14
|
+
[Parameter(Mandatory = $true)][string]$Message,
|
|
15
|
+
[Parameter(Mandatory = $true)][int]$ExitCode
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
[PSCustomObject]@{
|
|
19
|
+
ok = $false
|
|
20
|
+
status = 'failed'
|
|
21
|
+
code = $Code
|
|
22
|
+
message = $Message
|
|
23
|
+
interactionId = $InteractionId
|
|
24
|
+
} | ConvertTo-Json -Compress
|
|
25
|
+
exit $ExitCode
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if ([string]::IsNullOrWhiteSpace($InteractionId)) {
|
|
29
|
+
Write-AnswerMeCleanupFailure `
|
|
30
|
+
-Code 'interaction-id-missing' `
|
|
31
|
+
-Message 'InteractionId is required.' `
|
|
32
|
+
-ExitCode 2
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
$adapterPath = Join-Path $PSScriptRoot 'result-token-store.windows.ps1'
|
|
36
|
+
if (-not (Test-Path -LiteralPath $adapterPath -PathType Leaf)) {
|
|
37
|
+
Write-AnswerMeCleanupFailure `
|
|
38
|
+
-Code 'result-token-store-adapter-missing' `
|
|
39
|
+
-Message 'A protected Result Token Store adapter is required.' `
|
|
40
|
+
-ExitCode 3
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
. $adapterPath
|
|
45
|
+
$result = Remove-AnswerMeResultToken `
|
|
46
|
+
-InteractionId $InteractionId `
|
|
47
|
+
-StoreRoot $ResultTokenStoreRoot `
|
|
48
|
+
-AllowNonDefaultStoreRoot:$AllowNonDefaultResultTokenStore
|
|
49
|
+
[PSCustomObject]@{
|
|
50
|
+
ok = $true
|
|
51
|
+
status = 'credential-cleanup-complete'
|
|
52
|
+
interactionId = $InteractionId
|
|
53
|
+
removed = [bool]$result.removed
|
|
54
|
+
provider = $result.provider
|
|
55
|
+
} | ConvertTo-Json -Compress
|
|
56
|
+
exit 0
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
Write-AnswerMeCleanupFailure `
|
|
60
|
+
-Code 'result-token-cleanup-failed' `
|
|
61
|
+
-Message $_.Exception.Message `
|
|
62
|
+
-ExitCode 3
|
|
63
|
+
}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
$ErrorActionPreference = 'Stop'
|
|
2
|
+
|
|
3
|
+
$cryptoSupport = Join-Path $PSScriptRoot 'windows-crypto.ps1'
|
|
4
|
+
if (-not (Test-Path -LiteralPath $cryptoSupport -PathType Leaf)) {
|
|
5
|
+
throw 'The AnswerMe Windows cryptography helper is missing.'
|
|
6
|
+
}
|
|
7
|
+
. $cryptoSupport
|
|
8
|
+
|
|
9
|
+
function Get-AnswerMeDefaultResultTokenStoreRoot {
|
|
10
|
+
if ([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT) {
|
|
11
|
+
throw 'The Windows Result Token Store adapter requires Windows.'
|
|
12
|
+
}
|
|
13
|
+
$localAppData = [Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData)
|
|
14
|
+
if ([string]::IsNullOrWhiteSpace($localAppData)) {
|
|
15
|
+
throw 'LocalApplicationData is unavailable for the current Windows account.'
|
|
16
|
+
}
|
|
17
|
+
return [IO.Path]::GetFullPath((Join-Path $localAppData 'AnswerMe\result-token-credentials'))
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function Resolve-AnswerMeResultTokenStoreRoot {
|
|
21
|
+
param(
|
|
22
|
+
[string]$StoreRoot,
|
|
23
|
+
[switch]$AllowNonDefaultStoreRoot
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
$defaultRoot = Get-AnswerMeDefaultResultTokenStoreRoot
|
|
27
|
+
if ([string]::IsNullOrWhiteSpace($StoreRoot)) {
|
|
28
|
+
return $defaultRoot
|
|
29
|
+
}
|
|
30
|
+
$resolved = [IO.Path]::GetFullPath($StoreRoot)
|
|
31
|
+
if (-not $AllowNonDefaultStoreRoot -and
|
|
32
|
+
-not $resolved.Equals($defaultRoot, [StringComparison]::OrdinalIgnoreCase)) {
|
|
33
|
+
throw "A non-default Result Token Store root is allowed only for isolated tests: $resolved"
|
|
34
|
+
}
|
|
35
|
+
return $resolved
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function Get-AnswerMeResultTokenRecordHash {
|
|
39
|
+
param([Parameter(Mandatory = $true)][string]$InteractionId)
|
|
40
|
+
|
|
41
|
+
if ([string]::IsNullOrWhiteSpace($InteractionId)) {
|
|
42
|
+
throw 'InteractionId is required for Result Token storage.'
|
|
43
|
+
}
|
|
44
|
+
$idBytes = [Text.Encoding]::UTF8.GetBytes($InteractionId)
|
|
45
|
+
$hashBytes = $null
|
|
46
|
+
try {
|
|
47
|
+
$hashBytes = Get-AnswerMeSha256Bytes -Bytes $idBytes
|
|
48
|
+
return ConvertTo-AnswerMeLowerHex -Bytes $hashBytes
|
|
49
|
+
}
|
|
50
|
+
finally {
|
|
51
|
+
Clear-AnswerMeSensitiveByteArray -Bytes $idBytes
|
|
52
|
+
Clear-AnswerMeSensitiveByteArray -Bytes $hashBytes
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function Get-AnswerMeResultTokenCredentialPath {
|
|
57
|
+
param(
|
|
58
|
+
[Parameter(Mandatory = $true)][string]$InteractionId,
|
|
59
|
+
[Parameter(Mandatory = $true)][string]$ResolvedStoreRoot
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
$fileName = "{0}.clixml" -f (Get-AnswerMeResultTokenRecordHash -InteractionId $InteractionId)
|
|
63
|
+
$path = [IO.Path]::GetFullPath((Join-Path $ResolvedStoreRoot $fileName))
|
|
64
|
+
$parent = [IO.Path]::GetDirectoryName($path)
|
|
65
|
+
if (-not $parent.Equals($ResolvedStoreRoot, [StringComparison]::OrdinalIgnoreCase)) {
|
|
66
|
+
throw 'Resolved credential path escaped the Result Token Store root.'
|
|
67
|
+
}
|
|
68
|
+
return $path
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function Import-AnswerMeResultTokenCredential {
|
|
72
|
+
param(
|
|
73
|
+
[Parameter(Mandatory = $true)][string]$CredentialPath,
|
|
74
|
+
[Parameter(Mandatory = $true)][string]$InteractionId
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
$credential = Import-Clixml -LiteralPath $CredentialPath
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
throw 'The encrypted Result Token credential cannot be read by this Windows account.'
|
|
82
|
+
}
|
|
83
|
+
$expectedUserName = 'answerme-result-token:{0}' -f (
|
|
84
|
+
Get-AnswerMeResultTokenRecordHash -InteractionId $InteractionId
|
|
85
|
+
)
|
|
86
|
+
if ($credential -isnot [PSCredential] -or $credential.UserName -cne $expectedUserName) {
|
|
87
|
+
throw 'The encrypted Result Token credential does not match the requested interaction.'
|
|
88
|
+
}
|
|
89
|
+
return $credential
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function Assert-AnswerMeResultTokenStore {
|
|
93
|
+
[CmdletBinding()]
|
|
94
|
+
param(
|
|
95
|
+
[string]$StoreRoot,
|
|
96
|
+
[switch]$AllowNonDefaultStoreRoot
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
$resolvedRoot = Resolve-AnswerMeResultTokenStoreRoot `
|
|
100
|
+
-StoreRoot $StoreRoot `
|
|
101
|
+
-AllowNonDefaultStoreRoot:$AllowNonDefaultStoreRoot
|
|
102
|
+
New-Item -ItemType Directory -Path $resolvedRoot -Force | Out-Null
|
|
103
|
+
|
|
104
|
+
$probeId = "answerme-store-probe-$([Guid]::NewGuid().ToString('N'))"
|
|
105
|
+
$probePath = Join-Path $resolvedRoot ".$probeId.clixml"
|
|
106
|
+
$probeSecret = "probe-$([Guid]::NewGuid().ToString('N'))"
|
|
107
|
+
try {
|
|
108
|
+
$secure = ConvertTo-SecureString $probeSecret -AsPlainText -Force
|
|
109
|
+
$probeUserName = 'answerme-result-token:{0}' -f (
|
|
110
|
+
Get-AnswerMeResultTokenRecordHash -InteractionId $probeId
|
|
111
|
+
)
|
|
112
|
+
$credential = [PSCredential]::new($probeUserName, $secure)
|
|
113
|
+
$credential | Export-Clixml -LiteralPath $probePath
|
|
114
|
+
$roundTrip = Import-AnswerMeResultTokenCredential `
|
|
115
|
+
-CredentialPath $probePath `
|
|
116
|
+
-InteractionId $probeId
|
|
117
|
+
if ($roundTrip.GetNetworkCredential().Password -cne $probeSecret) {
|
|
118
|
+
throw 'DPAPI Result Token Store probe did not round-trip.'
|
|
119
|
+
}
|
|
120
|
+
return [PSCustomObject]@{
|
|
121
|
+
available = $true
|
|
122
|
+
provider = 'windows-dpapi'
|
|
123
|
+
storeRoot = $resolvedRoot
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
finally {
|
|
127
|
+
$secure = $null
|
|
128
|
+
$credential = $null
|
|
129
|
+
$roundTrip = $null
|
|
130
|
+
$probeSecret = $null
|
|
131
|
+
if (Test-Path -LiteralPath $probePath -PathType Leaf) {
|
|
132
|
+
Remove-Item -LiteralPath $probePath -Force
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function Save-AnswerMeResultToken {
|
|
138
|
+
[CmdletBinding()]
|
|
139
|
+
param(
|
|
140
|
+
[Parameter(Mandatory = $true)][string]$InteractionId,
|
|
141
|
+
[Parameter(Mandatory = $true)][string]$ResultToken,
|
|
142
|
+
[string]$StoreRoot,
|
|
143
|
+
[switch]$AllowNonDefaultStoreRoot
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
if ([string]::IsNullOrWhiteSpace($ResultToken)) {
|
|
147
|
+
throw 'Result Token is required for protected persistence.'
|
|
148
|
+
}
|
|
149
|
+
$resolvedRoot = Resolve-AnswerMeResultTokenStoreRoot `
|
|
150
|
+
-StoreRoot $StoreRoot `
|
|
151
|
+
-AllowNonDefaultStoreRoot:$AllowNonDefaultStoreRoot
|
|
152
|
+
New-Item -ItemType Directory -Path $resolvedRoot -Force | Out-Null
|
|
153
|
+
$credentialPath = Get-AnswerMeResultTokenCredentialPath `
|
|
154
|
+
-InteractionId $InteractionId `
|
|
155
|
+
-ResolvedStoreRoot $resolvedRoot
|
|
156
|
+
|
|
157
|
+
if (Test-Path -LiteralPath $credentialPath -PathType Leaf) {
|
|
158
|
+
$existing = Import-AnswerMeResultTokenCredential `
|
|
159
|
+
-CredentialPath $credentialPath `
|
|
160
|
+
-InteractionId $InteractionId
|
|
161
|
+
if ($existing.GetNetworkCredential().Password -cne $ResultToken) {
|
|
162
|
+
throw 'A different Result Token is already stored for this interaction.'
|
|
163
|
+
}
|
|
164
|
+
return [PSCustomObject]@{
|
|
165
|
+
persisted = $true
|
|
166
|
+
idempotentReplay = $true
|
|
167
|
+
provider = 'windows-dpapi'
|
|
168
|
+
interactionId = $InteractionId
|
|
169
|
+
credentialPath = $credentialPath
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
$temporaryPath = Join-Path $resolvedRoot ".$([Guid]::NewGuid().ToString('N')).tmp.clixml"
|
|
174
|
+
try {
|
|
175
|
+
$secureToken = ConvertTo-SecureString $ResultToken -AsPlainText -Force
|
|
176
|
+
$credentialUserName = 'answerme-result-token:{0}' -f (
|
|
177
|
+
Get-AnswerMeResultTokenRecordHash -InteractionId $InteractionId
|
|
178
|
+
)
|
|
179
|
+
$credential = [PSCredential]::new($credentialUserName, $secureToken)
|
|
180
|
+
$credential | Export-Clixml -LiteralPath $temporaryPath
|
|
181
|
+
$roundTrip = Import-AnswerMeResultTokenCredential `
|
|
182
|
+
-CredentialPath $temporaryPath `
|
|
183
|
+
-InteractionId $InteractionId
|
|
184
|
+
if ($roundTrip.GetNetworkCredential().Password -cne $ResultToken) {
|
|
185
|
+
throw 'The persisted Result Token failed readback verification.'
|
|
186
|
+
}
|
|
187
|
+
Move-Item -LiteralPath $temporaryPath -Destination $credentialPath
|
|
188
|
+
return [PSCustomObject]@{
|
|
189
|
+
persisted = $true
|
|
190
|
+
idempotentReplay = $false
|
|
191
|
+
provider = 'windows-dpapi'
|
|
192
|
+
interactionId = $InteractionId
|
|
193
|
+
credentialPath = $credentialPath
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
finally {
|
|
197
|
+
$secureToken = $null
|
|
198
|
+
$credential = $null
|
|
199
|
+
$roundTrip = $null
|
|
200
|
+
if (Test-Path -LiteralPath $temporaryPath -PathType Leaf) {
|
|
201
|
+
Remove-Item -LiteralPath $temporaryPath -Force
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function Get-AnswerMeResultToken {
|
|
207
|
+
[CmdletBinding()]
|
|
208
|
+
param(
|
|
209
|
+
[Parameter(Mandatory = $true)][string]$InteractionId,
|
|
210
|
+
[string]$StoreRoot,
|
|
211
|
+
[switch]$AllowNonDefaultStoreRoot
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
$resolvedRoot = Resolve-AnswerMeResultTokenStoreRoot `
|
|
215
|
+
-StoreRoot $StoreRoot `
|
|
216
|
+
-AllowNonDefaultStoreRoot:$AllowNonDefaultStoreRoot
|
|
217
|
+
$credentialPath = Get-AnswerMeResultTokenCredentialPath `
|
|
218
|
+
-InteractionId $InteractionId `
|
|
219
|
+
-ResolvedStoreRoot $resolvedRoot
|
|
220
|
+
if (-not (Test-Path -LiteralPath $credentialPath -PathType Leaf)) {
|
|
221
|
+
throw 'No protected Result Token credential exists for this interaction.'
|
|
222
|
+
}
|
|
223
|
+
$credential = Import-AnswerMeResultTokenCredential `
|
|
224
|
+
-CredentialPath $credentialPath `
|
|
225
|
+
-InteractionId $InteractionId
|
|
226
|
+
return $credential.GetNetworkCredential().Password
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function Remove-AnswerMeResultToken {
|
|
230
|
+
[CmdletBinding()]
|
|
231
|
+
param(
|
|
232
|
+
[Parameter(Mandatory = $true)][string]$InteractionId,
|
|
233
|
+
[string]$StoreRoot,
|
|
234
|
+
[switch]$AllowNonDefaultStoreRoot
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
$resolvedRoot = Resolve-AnswerMeResultTokenStoreRoot `
|
|
238
|
+
-StoreRoot $StoreRoot `
|
|
239
|
+
-AllowNonDefaultStoreRoot:$AllowNonDefaultStoreRoot
|
|
240
|
+
$credentialPath = Get-AnswerMeResultTokenCredentialPath `
|
|
241
|
+
-InteractionId $InteractionId `
|
|
242
|
+
-ResolvedStoreRoot $resolvedRoot
|
|
243
|
+
if (-not (Test-Path -LiteralPath $credentialPath -PathType Leaf)) {
|
|
244
|
+
return [PSCustomObject]@{
|
|
245
|
+
removed = $false
|
|
246
|
+
provider = 'windows-dpapi'
|
|
247
|
+
interactionId = $InteractionId
|
|
248
|
+
credentialPath = $credentialPath
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
Import-AnswerMeResultTokenCredential `
|
|
252
|
+
-CredentialPath $credentialPath `
|
|
253
|
+
-InteractionId $InteractionId | Out-Null
|
|
254
|
+
Remove-Item -LiteralPath $credentialPath -Force
|
|
255
|
+
return [PSCustomObject]@{
|
|
256
|
+
removed = $true
|
|
257
|
+
provider = 'windows-dpapi'
|
|
258
|
+
interactionId = $InteractionId
|
|
259
|
+
credentialPath = $credentialPath
|
|
260
|
+
}
|
|
261
|
+
}
|