@indigoai-us/hq-cli 5.341.2 → 5.341.4
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/CHANGELOG.md +12 -0
- package/assets/hqd/windows-pipe-host.ps1 +282 -0
- package/dist/commands/billing-sponsor.js +20 -1
- package/dist/commands/core-derive-trigger-facts-native.d.ts +2 -0
- package/dist/commands/core-derive-trigger-facts-native.js +92 -25
- package/dist/commands/core-migrate-policy-triggers-native.d.ts +1 -0
- package/dist/commands/core-migrate-policy-triggers-native.js +26 -12
- package/dist/commands/doctor.js +2 -2
- package/dist/lib/aws-sdk-node-warning-output.js +2 -3
- package/dist/lib/billing/plan-lock.js +19 -2
- package/dist/lib/bot/owner-context.d.ts +2 -0
- package/dist/lib/bot/owner-context.js +19 -1
- package/dist/lib/daemon/flag-monitor.d.ts +5 -0
- package/dist/lib/daemon/flag-monitor.js +14 -1
- package/dist/lib/daemon/protocol.d.ts +26 -3
- package/dist/lib/daemon/protocol.js +9 -5
- package/dist/lib/daemon/remote-control.d.ts +7 -0
- package/dist/lib/daemon/remote-control.js +135 -2
- package/dist/lib/daemon/run.js +14 -16
- package/dist/lib/daemon/server.d.ts +13 -0
- package/dist/lib/daemon/server.js +283 -28
- package/dist/lib/daemon/windows-pipe-host.d.ts +17 -0
- package/dist/lib/daemon/windows-pipe-host.js +218 -0
- package/dist/lib/diagnostics.d.ts +9 -0
- package/dist/lib/diagnostics.js +12 -0
- package/dist/lib/doctor/checks/anywhere.d.ts +6 -1
- package/dist/lib/doctor/checks/anywhere.js +131 -13
- package/dist/lib/doctor/json-output.d.ts +2 -0
- package/dist/lib/doctor/json-output.js +1 -0
- package/dist/lib/doctor/registry.js +1 -3
- package/dist/lib/doctor/types.d.ts +4 -0
- package/dist/utils/run-bundled-script.d.ts +2 -0
- package/dist/utils/run-bundled-script.js +10 -1
- package/docs/anywhere-runtime.md +32 -5
- package/package.json +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.341.4] — 2026-10-03
|
|
6
|
+
- Prevent daemon restarts from waiting on stale or already-owned Claude bridge sessions, and keep captured startup diagnostics useful without exposing authorization credentials.
|
|
7
|
+
|
|
8
|
+
## [5.341.3] — 2026-10-03
|
|
9
|
+
- Preserve policy-trigger parity for Windows PATH resolution, filesystem paths, and frontmatter parsing.
|
|
10
|
+
- Report unexpected company-resolve, plan-limit body and late owner-context failures that a bare catch used to hide. The report prints only with `--verbose` or `HQ_DEBUG` set.
|
|
11
|
+
- Enable hqd on Windows through a bundled PowerShell named-pipe host that applies and verifies a current-user-only DACL, while refusing startup if the security check fails.
|
|
12
|
+
- `hq doctor` now reports hqd reachability, runtime flag freshness and fallback
|
|
13
|
+
reasons for the current directory. The same fields are available with
|
|
14
|
+
`hq doctor --json`, including while the runtime flag is off.
|
|
15
|
+
- Hardened Unix hqd socket startup to reject symlinks anywhere in the socket directory path and unexpected socket-path entries, replace only a stale socket owned by the current user, and verify user-only directory and socket permissions. The default socket now lives under the private `~/.hq/daemon/` directory.
|
|
16
|
+
|
|
5
17
|
## [5.341.2] — 2026-10-03
|
|
6
18
|
|
|
7
19
|
- Preserve the original integrations response read error instead of returning a consumed response.
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
$ErrorActionPreference = 'Stop'
|
|
2
|
+
[Console]::InputEncoding = New-Object System.Text.UTF8Encoding($false)
|
|
3
|
+
[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false)
|
|
4
|
+
try {
|
|
5
|
+
Add-Type -AssemblyName System.IO.Pipes.AccessControl -ErrorAction SilentlyContinue
|
|
6
|
+
} catch {
|
|
7
|
+
# Windows PowerShell 5.1 ships the access-control types in its framework assemblies.
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
$source = @'
|
|
11
|
+
using System;
|
|
12
|
+
using System.Collections;
|
|
13
|
+
using System.Collections.Concurrent;
|
|
14
|
+
using System.IO;
|
|
15
|
+
using System.IO.Pipes;
|
|
16
|
+
using System.Linq;
|
|
17
|
+
using System.Reflection;
|
|
18
|
+
using System.Security.AccessControl;
|
|
19
|
+
using System.Security.Principal;
|
|
20
|
+
using System.Text;
|
|
21
|
+
using System.Threading;
|
|
22
|
+
using System.Threading.Tasks;
|
|
23
|
+
using Microsoft.Win32.SafeHandles;
|
|
24
|
+
using System.Runtime.InteropServices;
|
|
25
|
+
|
|
26
|
+
public static class HqdWindowsPipeHost
|
|
27
|
+
{
|
|
28
|
+
private static readonly object OutputLock = new object();
|
|
29
|
+
private static readonly ConcurrentDictionary<int, NamedPipeServerStream> Connections = new ConcurrentDictionary<int, NamedPipeServerStream>();
|
|
30
|
+
private static int NextConnectionId;
|
|
31
|
+
private static volatile bool Stopping;
|
|
32
|
+
private static readonly ManualResetEventSlim AcceptStarted = new ManualResetEventSlim(false);
|
|
33
|
+
private static string PipeName;
|
|
34
|
+
private static SecurityIdentifier UserSid;
|
|
35
|
+
private static PipeSecurity Security;
|
|
36
|
+
|
|
37
|
+
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
|
38
|
+
private static extern bool GetNamedPipeClientComputerName(SafePipeHandle pipe, StringBuilder computerName, uint length);
|
|
39
|
+
|
|
40
|
+
private static void Emit(string frame)
|
|
41
|
+
{
|
|
42
|
+
lock (OutputLock)
|
|
43
|
+
{
|
|
44
|
+
Console.Out.WriteLine(frame);
|
|
45
|
+
Console.Out.Flush();
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private static string B64(string value)
|
|
50
|
+
{
|
|
51
|
+
return Convert.ToBase64String(Encoding.UTF8.GetBytes(value));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
private static void EmitFatal(Exception error)
|
|
55
|
+
{
|
|
56
|
+
Emit("FATAL\t" + B64(error.GetBaseException().Message));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
private static PipeSecurity BuildSecurity(SecurityIdentifier sid)
|
|
60
|
+
{
|
|
61
|
+
PipeSecurity security = new PipeSecurity();
|
|
62
|
+
security.SetAccessRuleProtection(true, false);
|
|
63
|
+
security.AddAccessRule(new PipeAccessRule(sid, PipeAccessRights.FullControl, AccessControlType.Allow));
|
|
64
|
+
return security;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
private static void VerifySecurity(NamedPipeServerStream pipe)
|
|
68
|
+
{
|
|
69
|
+
PipeSecurity actual = pipe.GetAccessControl();
|
|
70
|
+
AuthorizationRuleCollection rules = actual.GetAccessRules(true, true, typeof(SecurityIdentifier));
|
|
71
|
+
if (rules.Count != 1)
|
|
72
|
+
throw new InvalidOperationException("created pipe DACL has an unexpected number of ACEs");
|
|
73
|
+
PipeAccessRule rule = rules[0] as PipeAccessRule;
|
|
74
|
+
if (rule == null || rule.AccessControlType != AccessControlType.Allow ||
|
|
75
|
+
!UserSid.Equals(rule.IdentityReference) || rule.PipeAccessRights != PipeAccessRights.FullControl ||
|
|
76
|
+
rule.InheritanceFlags != InheritanceFlags.None || rule.PropagationFlags != PropagationFlags.None ||
|
|
77
|
+
rule.IsInherited)
|
|
78
|
+
throw new InvalidOperationException("created pipe DACL is not exactly current-user full control");
|
|
79
|
+
if (!actual.AreAccessRulesProtected)
|
|
80
|
+
throw new InvalidOperationException("created pipe DACL is not protected from inherited ACEs");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private static NamedPipeServerStream CreatePipe(bool first)
|
|
84
|
+
{
|
|
85
|
+
PipeOptions options = PipeOptions.Asynchronous;
|
|
86
|
+
string[] names = Enum.GetNames(typeof(PipeOptions));
|
|
87
|
+
if (first && names.Contains("FirstPipeInstance"))
|
|
88
|
+
options |= (PipeOptions)Enum.Parse(typeof(PipeOptions), "FirstPipeInstance");
|
|
89
|
+
|
|
90
|
+
int instances = 254;
|
|
91
|
+
Type aclFactory = Type.GetType("System.IO.Pipes.NamedPipeServerStreamAcl, System.IO.Pipes.AccessControl", false);
|
|
92
|
+
if (aclFactory != null)
|
|
93
|
+
{
|
|
94
|
+
MethodInfo method = aclFactory.GetMethods(BindingFlags.Public | BindingFlags.Static)
|
|
95
|
+
.Where(m => m.Name == "Create")
|
|
96
|
+
.FirstOrDefault(m => {
|
|
97
|
+
ParameterInfo[] p = m.GetParameters();
|
|
98
|
+
return p.Length >= 8 && p[0].ParameterType == typeof(string) && p[7].ParameterType == typeof(PipeSecurity);
|
|
99
|
+
});
|
|
100
|
+
if (method != null)
|
|
101
|
+
{
|
|
102
|
+
ParameterInfo[] parameters = method.GetParameters();
|
|
103
|
+
object[] args = new object[parameters.Length];
|
|
104
|
+
args[0] = PipeName;
|
|
105
|
+
args[1] = PipeDirection.InOut;
|
|
106
|
+
args[2] = instances;
|
|
107
|
+
args[3] = PipeTransmissionMode.Byte;
|
|
108
|
+
args[4] = options;
|
|
109
|
+
args[5] = 65536;
|
|
110
|
+
args[6] = 65536;
|
|
111
|
+
args[7] = Security;
|
|
112
|
+
for (int i = 8; i < args.Length; i++)
|
|
113
|
+
args[i] = parameters[i].DefaultValue;
|
|
114
|
+
return (NamedPipeServerStream)method.Invoke(null, args);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
ConstructorInfo constructor = typeof(NamedPipeServerStream).GetConstructor(new Type[] {
|
|
119
|
+
typeof(string), typeof(PipeDirection), typeof(int), typeof(PipeTransmissionMode),
|
|
120
|
+
typeof(PipeOptions), typeof(int), typeof(int), typeof(PipeSecurity)
|
|
121
|
+
});
|
|
122
|
+
if (constructor == null)
|
|
123
|
+
throw new PlatformNotSupportedException("this PowerShell runtime has no ACL-capable named pipe constructor");
|
|
124
|
+
return (NamedPipeServerStream)constructor.Invoke(new object[] {
|
|
125
|
+
PipeName, PipeDirection.InOut, instances, PipeTransmissionMode.Byte,
|
|
126
|
+
options, 65536, 65536, Security
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
private static bool IsLocalClient(NamedPipeServerStream pipe)
|
|
131
|
+
{
|
|
132
|
+
StringBuilder clientName = new StringBuilder(256);
|
|
133
|
+
uint bufferLengthBytes = (uint)(clientName.Capacity * 2);
|
|
134
|
+
if (!GetNamedPipeClientComputerName(pipe.SafePipeHandle, clientName, bufferLengthBytes))
|
|
135
|
+
{
|
|
136
|
+
// ERROR_PIPE_LOCAL (229), not ERROR_PIPE_NOT_CONNECTED (233),
|
|
137
|
+
// means the client connected through the local NPFS path.
|
|
138
|
+
int error = Marshal.GetLastWin32Error();
|
|
139
|
+
if (error == 229) return true;
|
|
140
|
+
throw new InvalidOperationException("could not verify the named-pipe client computer (Win32 error " + error + ")");
|
|
141
|
+
}
|
|
142
|
+
// A successful lookup identifies a remote SMB client.
|
|
143
|
+
Emit("REJECT\t" + B64("remote named-pipe client computer " + clientName));
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private static void Relay(int id, NamedPipeServerStream pipe)
|
|
148
|
+
{
|
|
149
|
+
try
|
|
150
|
+
{
|
|
151
|
+
byte[] buffer = new byte[32768];
|
|
152
|
+
while (!Stopping && pipe.IsConnected)
|
|
153
|
+
{
|
|
154
|
+
int count = pipe.Read(buffer, 0, buffer.Length);
|
|
155
|
+
if (count <= 0) break;
|
|
156
|
+
string data = Convert.ToBase64String(buffer, 0, count);
|
|
157
|
+
Emit("DATA\t" + id + "\t" + data);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
catch (ObjectDisposedException) { }
|
|
161
|
+
catch (IOException) { }
|
|
162
|
+
finally
|
|
163
|
+
{
|
|
164
|
+
NamedPipeServerStream ignored;
|
|
165
|
+
Connections.TryRemove(id, out ignored);
|
|
166
|
+
try { pipe.Dispose(); } catch { }
|
|
167
|
+
Emit("CLOSE\t" + id);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private static void AcceptLoop(NamedPipeServerStream first)
|
|
172
|
+
{
|
|
173
|
+
NamedPipeServerStream listener = first;
|
|
174
|
+
try
|
|
175
|
+
{
|
|
176
|
+
AcceptStarted.Wait();
|
|
177
|
+
while (!Stopping)
|
|
178
|
+
{
|
|
179
|
+
listener.WaitForConnection();
|
|
180
|
+
if (!IsLocalClient(listener))
|
|
181
|
+
{
|
|
182
|
+
listener.Dispose();
|
|
183
|
+
listener = CreatePipe(false);
|
|
184
|
+
VerifySecurity(listener);
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
NamedPipeServerStream connected = listener;
|
|
189
|
+
listener = CreatePipe(false);
|
|
190
|
+
VerifySecurity(listener);
|
|
191
|
+
int id = Interlocked.Increment(ref NextConnectionId);
|
|
192
|
+
Connections[id] = connected;
|
|
193
|
+
Emit("OPEN\t" + id);
|
|
194
|
+
Task.Run(() => Relay(id, connected));
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
catch (ObjectDisposedException error)
|
|
198
|
+
{
|
|
199
|
+
if (!Stopping)
|
|
200
|
+
{
|
|
201
|
+
EmitFatal(error);
|
|
202
|
+
Stopping = true;
|
|
203
|
+
foreach (NamedPipeServerStream pipe in Connections.Values)
|
|
204
|
+
try { pipe.Dispose(); } catch { }
|
|
205
|
+
try { listener.Dispose(); } catch { }
|
|
206
|
+
Environment.ExitCode = 1;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
catch (Exception error)
|
|
210
|
+
{
|
|
211
|
+
EmitFatal(error);
|
|
212
|
+
Stopping = true;
|
|
213
|
+
foreach (NamedPipeServerStream pipe in Connections.Values)
|
|
214
|
+
try { pipe.Dispose(); } catch { }
|
|
215
|
+
try { listener.Dispose(); } catch { }
|
|
216
|
+
Environment.ExitCode = 1;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
private static void ReadControl()
|
|
221
|
+
{
|
|
222
|
+
string line;
|
|
223
|
+
while (!Stopping && (line = Console.In.ReadLine()) != null)
|
|
224
|
+
{
|
|
225
|
+
string[] fields = line.Split('\t');
|
|
226
|
+
if (fields[0] == "START")
|
|
227
|
+
{
|
|
228
|
+
AcceptStarted.Set();
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
if (fields.Length < 2) continue;
|
|
232
|
+
int id;
|
|
233
|
+
if (!Int32.TryParse(fields[1], out id)) continue;
|
|
234
|
+
NamedPipeServerStream pipe;
|
|
235
|
+
if (!Connections.TryGetValue(id, out pipe)) continue;
|
|
236
|
+
try
|
|
237
|
+
{
|
|
238
|
+
if (fields[0] == "WRITE" && fields.Length == 3)
|
|
239
|
+
{
|
|
240
|
+
byte[] bytes = Convert.FromBase64String(fields[2]);
|
|
241
|
+
pipe.Write(bytes, 0, bytes.Length);
|
|
242
|
+
pipe.Flush();
|
|
243
|
+
}
|
|
244
|
+
else if (fields[0] == "CLOSE")
|
|
245
|
+
{
|
|
246
|
+
pipe.Dispose();
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
catch (ObjectDisposedException) { }
|
|
250
|
+
catch (IOException) { }
|
|
251
|
+
}
|
|
252
|
+
Stopping = true;
|
|
253
|
+
foreach (NamedPipeServerStream pipe in Connections.Values)
|
|
254
|
+
try { pipe.Dispose(); } catch { }
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
public static void Run(string pipeName)
|
|
258
|
+
{
|
|
259
|
+
PipeName = pipeName;
|
|
260
|
+
UserSid = WindowsIdentity.GetCurrent().User;
|
|
261
|
+
if (UserSid == null) throw new InvalidOperationException("current Windows user SID is unavailable");
|
|
262
|
+
Security = BuildSecurity(UserSid);
|
|
263
|
+
NamedPipeServerStream first = CreatePipe(true);
|
|
264
|
+
VerifySecurity(first);
|
|
265
|
+
string sddl = first.GetAccessControl().GetSecurityDescriptorSddlForm(AccessControlSections.Access);
|
|
266
|
+
Emit("READY\t" + UserSid.Value + "\t" + B64(sddl));
|
|
267
|
+
Task accept = Task.Run(() => AcceptLoop(first));
|
|
268
|
+
ReadControl();
|
|
269
|
+
Stopping = true;
|
|
270
|
+
try { first.Dispose(); } catch { }
|
|
271
|
+
try { accept.Wait(1000); } catch { }
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
'@
|
|
275
|
+
|
|
276
|
+
try {
|
|
277
|
+
Add-Type -TypeDefinition $source -ErrorAction Stop
|
|
278
|
+
[HqdWindowsPipeHost]::Run($env:HQD_PIPE_NAME)
|
|
279
|
+
} catch {
|
|
280
|
+
[Console]::Error.WriteLine($_.Exception.GetBaseException().Message)
|
|
281
|
+
exit 1
|
|
282
|
+
}
|
|
@@ -27,7 +27,9 @@
|
|
|
27
27
|
* a feature that simply is not turned on.
|
|
28
28
|
*/
|
|
29
29
|
import chalk from "chalk";
|
|
30
|
+
import { diagnosticsEnabled } from "../lib/diagnostics.js";
|
|
30
31
|
import { ensureCognitoToken } from "../utils/cognito-session.js";
|
|
32
|
+
import { isCompanySelectionError } from "../utils/company-selection-error.js";
|
|
31
33
|
import { vaultApiFetch, getCompanyUid } from "../utils/vault-api.js";
|
|
32
34
|
/** The request kind this command files. Matches hq-pro's contract. */
|
|
33
35
|
export const PLAN_RESOURCE = "plan";
|
|
@@ -45,6 +47,20 @@ export const HELD_FOR_CARD_SENTENCE = "Your company said yes and is adding a car
|
|
|
45
47
|
class SponsorError extends Error {
|
|
46
48
|
expected = true;
|
|
47
49
|
}
|
|
50
|
+
function isExpectedCompanyResolveFailure(error) {
|
|
51
|
+
if (isCompanySelectionError(error))
|
|
52
|
+
return true;
|
|
53
|
+
return (typeof error === "object" &&
|
|
54
|
+
error !== null &&
|
|
55
|
+
"expected" in error &&
|
|
56
|
+
error.expected === true);
|
|
57
|
+
}
|
|
58
|
+
function noteSponsorCompanyUnresolved(error) {
|
|
59
|
+
if (isExpectedCompanyResolveFailure(error) || !diagnosticsEnabled())
|
|
60
|
+
return;
|
|
61
|
+
const errorName = error instanceof Error ? error.name : "Error";
|
|
62
|
+
console.error(`billing-sponsor company resolve failed errorName=${errorName}`);
|
|
63
|
+
}
|
|
48
64
|
function fail(err) {
|
|
49
65
|
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
|
|
50
66
|
process.exit(1);
|
|
@@ -185,7 +201,10 @@ export function registerBillingSponsorCommands(billing) {
|
|
|
185
201
|
// this feature is for often belongs to no company at all, and they
|
|
186
202
|
// still need to be told what to do next.
|
|
187
203
|
const named = opts.company ?? billing.opts().company;
|
|
188
|
-
const companyUid = await getCompanyUid(token, named).catch(() =>
|
|
204
|
+
const companyUid = await getCompanyUid(token, named).catch((error) => {
|
|
205
|
+
noteSponsorCompanyUnresolved(error);
|
|
206
|
+
return null;
|
|
207
|
+
});
|
|
189
208
|
// The admin queue is the richer view. A non-admin is refused it, so we
|
|
190
209
|
// fall back to the member SELF-READ — a different route with a
|
|
191
210
|
// different answer: hq-pro returns ONE status for that person and that
|
|
@@ -15,6 +15,8 @@ export type DeriveTriggerRunResult = {
|
|
|
15
15
|
stdout: Buffer;
|
|
16
16
|
stderr: Buffer;
|
|
17
17
|
};
|
|
18
|
+
export declare function resolveWindowsFilesystemPath(candidate: string, cwd: string, resolveRealpath?: (value: string) => string, isDirectory?: (value: string) => boolean): string | undefined;
|
|
19
|
+
export declare function commandOnPath(command: string, env: Record<string, string | undefined>, platform?: NodeJS.Platform): boolean;
|
|
18
20
|
/** Run the native handler contract, retaining exact bytes for differential tests. */
|
|
19
21
|
export declare function runDeriveTriggerFacts(options: DeriveTriggerRunOptions): DeriveTriggerRunResult;
|
|
20
22
|
export declare function runDeriveTriggerFactsFast(argv: readonly string[], stdin: string, cwd: string, hqRoot: string | undefined): number;
|
|
@@ -4,7 +4,6 @@ import * as path from "node:path";
|
|
|
4
4
|
import { runBundledScript } from "../utils/run-bundled-script.js";
|
|
5
5
|
import { isByteOrientedLocale } from "../lib/core-utils/byte-oriented-locale.js";
|
|
6
6
|
export const DERIVE_TRIGGER_FACTS_ASSET = "core/scripts/derive-trigger-facts.sh";
|
|
7
|
-
const TEXT_FACT_AWK = "core/scripts/lib/trigger-fact-text.awk";
|
|
8
7
|
const TRANSCRIPT_TAIL_MAX_BYTES = 1_048_576;
|
|
9
8
|
function effectiveEnvironment(options) {
|
|
10
9
|
return {
|
|
@@ -62,13 +61,51 @@ function globSegmentMatches(pattern, value) {
|
|
|
62
61
|
return false;
|
|
63
62
|
}
|
|
64
63
|
}
|
|
64
|
+
export function resolveWindowsFilesystemPath(candidate, cwd, resolveRealpath = (value) => fs.realpathSync.native(value), isDirectory = (value) => fs.statSync(value).isDirectory()) {
|
|
65
|
+
const value = candidate || ".";
|
|
66
|
+
// Node's win32 path helpers collapse `link/..` lexically before the OS can
|
|
67
|
+
// follow `link`. Bash walks the path component by component, so resolve a
|
|
68
|
+
// symlink before applying each parent component while keeping the emitted
|
|
69
|
+
// glob word untouched.
|
|
70
|
+
// Git Bash exposes mounted Windows drives as /c/... while Node's win32
|
|
71
|
+
// path parser treats that spelling as a path rooted at the current drive.
|
|
72
|
+
// Convert only the drive-mount prefix before probing the filesystem.
|
|
73
|
+
const windowsValue = value.replace(/^\/([A-Za-z])(?:\/|$)/, (_match, drive) => `${drive.toUpperCase()}:\\`);
|
|
74
|
+
const absolute = path.win32.isAbsolute(windowsValue);
|
|
75
|
+
const parsed = path.win32.parse(absolute ? windowsValue : cwd);
|
|
76
|
+
let current = absolute ? parsed.root : cwd;
|
|
77
|
+
const rest = absolute ? windowsValue.slice(parsed.root.length) : windowsValue;
|
|
78
|
+
for (const segment of rest.split(/[\\/]+/)) {
|
|
79
|
+
if (!segment || segment === ".")
|
|
80
|
+
continue;
|
|
81
|
+
if (segment === "..") {
|
|
82
|
+
try {
|
|
83
|
+
current = resolveRealpath(current);
|
|
84
|
+
if (!isDirectory(current))
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
current = path.win32.dirname(current);
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
current = path.win32.join(current, segment);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return current;
|
|
97
|
+
}
|
|
65
98
|
function filesystemPath(candidate, cwd) {
|
|
66
99
|
const value = candidate || ".";
|
|
67
|
-
|
|
100
|
+
if (process.platform !== "win32") {
|
|
101
|
+
return path.isAbsolute(value) ? value : `${cwd}${path.sep}${value}`;
|
|
102
|
+
}
|
|
103
|
+
return resolveWindowsFilesystemPath(value, cwd);
|
|
68
104
|
}
|
|
69
105
|
function isDirectoryPath(candidate, cwd) {
|
|
70
106
|
try {
|
|
71
|
-
|
|
107
|
+
const resolved = filesystemPath(candidate, cwd);
|
|
108
|
+
return resolved !== undefined && fs.statSync(resolved).isDirectory();
|
|
72
109
|
}
|
|
73
110
|
catch {
|
|
74
111
|
return false;
|
|
@@ -76,7 +113,10 @@ function isDirectoryPath(candidate, cwd) {
|
|
|
76
113
|
}
|
|
77
114
|
function pathEntryExists(candidate, cwd) {
|
|
78
115
|
try {
|
|
79
|
-
|
|
116
|
+
const resolved = filesystemPath(candidate, cwd);
|
|
117
|
+
if (resolved === undefined)
|
|
118
|
+
return false;
|
|
119
|
+
fs.lstatSync(resolved);
|
|
80
120
|
return true;
|
|
81
121
|
}
|
|
82
122
|
catch {
|
|
@@ -86,16 +126,17 @@ function pathEntryExists(candidate, cwd) {
|
|
|
86
126
|
function expandBashGlob(token, cwd) {
|
|
87
127
|
if (!hasGlobPattern(token))
|
|
88
128
|
return [token];
|
|
89
|
-
|
|
90
|
-
const
|
|
91
|
-
const
|
|
129
|
+
// Bash emits slash-separated words even when running under Git Bash on Windows.
|
|
130
|
+
const absolute = token.startsWith("/");
|
|
131
|
+
const trailingSlash = token.endsWith("/");
|
|
132
|
+
const parts = token.split("/").filter((part, index) => !(absolute && index === 0));
|
|
92
133
|
if (trailingSlash)
|
|
93
134
|
parts.pop();
|
|
94
|
-
let candidates = [absolute ?
|
|
135
|
+
let candidates = [absolute ? "/" : ""];
|
|
95
136
|
let matchedPattern = false;
|
|
96
137
|
for (const [index, part] of parts.entries()) {
|
|
97
138
|
if (!hasGlobPattern(part)) {
|
|
98
|
-
candidates = candidates.map((parent) => !parent ? part : parent ===
|
|
139
|
+
candidates = candidates.map((parent) => !parent ? part : parent === "/" ? `/${part}` : `${parent}/${part}`);
|
|
99
140
|
if (matchedPattern) {
|
|
100
141
|
const needsDirectory = index < parts.length - 1 || trailingSlash;
|
|
101
142
|
candidates = candidates.filter((candidate) => needsDirectory ? isDirectoryPath(candidate, cwd) : pathEntryExists(candidate, cwd));
|
|
@@ -106,6 +147,8 @@ function expandBashGlob(token, cwd) {
|
|
|
106
147
|
const expanded = [];
|
|
107
148
|
for (const parent of candidates) {
|
|
108
149
|
const directory = filesystemPath(parent || ".", cwd);
|
|
150
|
+
if (directory === undefined)
|
|
151
|
+
continue;
|
|
109
152
|
let names;
|
|
110
153
|
try {
|
|
111
154
|
names = fs.readdirSync(directory).sort();
|
|
@@ -118,7 +161,7 @@ function expandBashGlob(token, cwd) {
|
|
|
118
161
|
continue;
|
|
119
162
|
if (!globSegmentMatches(part, name))
|
|
120
163
|
continue;
|
|
121
|
-
expanded.push(!parent ? name : parent ===
|
|
164
|
+
expanded.push(!parent ? name : parent === "/" ? `/${name}` : `${parent}/${name}`);
|
|
122
165
|
}
|
|
123
166
|
}
|
|
124
167
|
if (expanded.length === 0)
|
|
@@ -128,7 +171,7 @@ function expandBashGlob(token, cwd) {
|
|
|
128
171
|
if (trailingSlash) {
|
|
129
172
|
candidates = candidates
|
|
130
173
|
.filter((candidate) => isDirectoryPath(candidate, cwd))
|
|
131
|
-
.map((candidate) => candidate.endsWith(
|
|
174
|
+
.map((candidate) => candidate.endsWith("/") ? candidate : `${candidate}/`)
|
|
132
175
|
.sort();
|
|
133
176
|
}
|
|
134
177
|
return matchedPattern && candidates.length > 0 ? candidates : [token];
|
|
@@ -136,16 +179,27 @@ function expandBashGlob(token, cwd) {
|
|
|
136
179
|
function bashUnquotedWords(facts, cwd) {
|
|
137
180
|
return facts.join(" ").split(/[\t\n ]+/).filter(Boolean).flatMap((word) => expandBashGlob(word, cwd));
|
|
138
181
|
}
|
|
139
|
-
function commandOnPath(command, env) {
|
|
182
|
+
export function commandOnPath(command, env, platform = process.platform) {
|
|
140
183
|
const pathValue = env.PATH ?? "";
|
|
184
|
+
const extensions = platform === "win32"
|
|
185
|
+
? ["", ...(env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)]
|
|
186
|
+
: [""];
|
|
141
187
|
for (const directory of pathValue.split(path.delimiter)) {
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
188
|
+
for (const extension of extensions) {
|
|
189
|
+
const candidate = path.join(directory || process.cwd(), `${command}${extension}`);
|
|
190
|
+
try {
|
|
191
|
+
if (platform === "win32") {
|
|
192
|
+
if (fs.statSync(candidate).isFile())
|
|
193
|
+
return true;
|
|
194
|
+
}
|
|
195
|
+
else {
|
|
196
|
+
fs.accessSync(candidate, fs.constants.X_OK);
|
|
197
|
+
return true;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
// PATH probes intentionally ignore missing and inaccessible entries.
|
|
202
|
+
}
|
|
149
203
|
}
|
|
150
204
|
}
|
|
151
205
|
return false;
|
|
@@ -185,10 +239,12 @@ function parsePayload(raw) {
|
|
|
185
239
|
parsed = JSON.parse(raw);
|
|
186
240
|
}
|
|
187
241
|
catch {
|
|
188
|
-
|
|
242
|
+
// The shell redirects jq's parse diagnostic and leaves every field empty.
|
|
243
|
+
return emptyPayload();
|
|
189
244
|
}
|
|
190
245
|
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
191
|
-
|
|
246
|
+
// jq's object indexing fails quietly through the same redirected pipeline.
|
|
247
|
+
return emptyPayload();
|
|
192
248
|
}
|
|
193
249
|
const record = parsed;
|
|
194
250
|
const toolResponse = jqField(record.tool_response);
|
|
@@ -207,6 +263,12 @@ function parsePayload(raw) {
|
|
|
207
263
|
}
|
|
208
264
|
return fields;
|
|
209
265
|
}
|
|
266
|
+
function emptyPayload() {
|
|
267
|
+
return {
|
|
268
|
+
tool: "", command: "", runInBackground: "", toolResponse: "", prompt: "",
|
|
269
|
+
cwd: "", sessionId: "", transcriptPath: "",
|
|
270
|
+
};
|
|
271
|
+
}
|
|
210
272
|
function matchTextFacts(rawText, env) {
|
|
211
273
|
const text = localeLower(rawText, env);
|
|
212
274
|
const facts = [];
|
|
@@ -391,9 +453,8 @@ function computeOutput(options) {
|
|
|
391
453
|
const withAssistantIntent = args[1] === "--with-assistant-intent";
|
|
392
454
|
if (args.length === 2 && !withAssistantIntent)
|
|
393
455
|
throw new ShellFallbackRequired("unsupported argv");
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
}
|
|
456
|
+
// The shell accepts unknown event names and emits the baseline `always`
|
|
457
|
+
// fact (plus any static session facts); it does not validate the event.
|
|
397
458
|
const paired = withAssistantIntent && (event === "PreToolUse" || event === "UserPromptSubmit");
|
|
398
459
|
const env = effectiveEnvironment(options);
|
|
399
460
|
const jqAvailable = options.jqAvailable ?? commandOnPath("jq", env);
|
|
@@ -465,6 +526,7 @@ function shellFallback(options) {
|
|
|
465
526
|
args: [...options.argv],
|
|
466
527
|
cwd: options.hqRoot,
|
|
467
528
|
hqRoot: options.hqRoot,
|
|
529
|
+
msysScriptPath: true,
|
|
468
530
|
stdin: options.stdin,
|
|
469
531
|
captureOutput: true,
|
|
470
532
|
env,
|
|
@@ -487,12 +549,17 @@ function missingSupportLibrary(hqRoot) {
|
|
|
487
549
|
],
|
|
488
550
|
];
|
|
489
551
|
for (const [relativePath, message] of required) {
|
|
552
|
+
let descriptor;
|
|
490
553
|
try {
|
|
491
|
-
fs.
|
|
554
|
+
descriptor = fs.openSync(path.join(hqRoot, relativePath), "r");
|
|
492
555
|
}
|
|
493
556
|
catch {
|
|
494
557
|
return message;
|
|
495
558
|
}
|
|
559
|
+
finally {
|
|
560
|
+
if (descriptor !== undefined)
|
|
561
|
+
fs.closeSync(descriptor);
|
|
562
|
+
}
|
|
496
563
|
}
|
|
497
564
|
return undefined;
|
|
498
565
|
}
|
|
@@ -12,6 +12,7 @@ export type MigratePolicyDependencies = {
|
|
|
12
12
|
error?: Error;
|
|
13
13
|
};
|
|
14
14
|
};
|
|
15
|
+
export declare function executableInPath(name: string, env: NodeJS.ProcessEnv, fileSystem: typeof fs, platform?: NodeJS.Platform): boolean;
|
|
15
16
|
export declare function runMigratePolicyTriggers(args: readonly string[], deps: MigratePolicyDependencies): {
|
|
16
17
|
code: number;
|
|
17
18
|
stdout: string;
|
|
@@ -57,16 +57,24 @@ function isDirectory(fileSystem, value) {
|
|
|
57
57
|
return false;
|
|
58
58
|
}
|
|
59
59
|
}
|
|
60
|
-
function executableInPath(name, env, fileSystem) {
|
|
60
|
+
export function executableInPath(name, env, fileSystem, platform = process.platform) {
|
|
61
|
+
const extensions = platform === "win32"
|
|
62
|
+
// spawnSync("flock", ...) without shell support can launch native images,
|
|
63
|
+
// but not batch files. Treating .cmd/.bat as available skips the fallback
|
|
64
|
+
// lock while the later spawn fails on Windows.
|
|
65
|
+
? (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter((extension) => /^\.(?:COM|EXE)$/i.test(extension))
|
|
66
|
+
: [""];
|
|
61
67
|
for (const directory of (env.PATH ?? "").split(path.delimiter)) {
|
|
62
68
|
if (!directory)
|
|
63
69
|
continue;
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
70
|
+
for (const extension of extensions) {
|
|
71
|
+
try {
|
|
72
|
+
const stat = fileSystem.statSync(path.join(directory, `${name}${extension}`));
|
|
73
|
+
if (stat.isFile() && (platform === "win32" || (stat.mode & 0o111) !== 0))
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
catch { /* continue through PATH */ }
|
|
68
77
|
}
|
|
69
|
-
catch { /* continue through PATH */ }
|
|
70
78
|
}
|
|
71
79
|
return false;
|
|
72
80
|
}
|
|
@@ -79,7 +87,9 @@ function runNonblockingFlock(lockFd, cwd, pathValue) {
|
|
|
79
87
|
}
|
|
80
88
|
function frontmatterValue(content, key) {
|
|
81
89
|
let depth = 0;
|
|
82
|
-
for (const
|
|
90
|
+
for (const rawLine of content.split("\n")) {
|
|
91
|
+
// Git for Windows' awk reads CRLF as a line ending; POSIX awk keeps the CR.
|
|
92
|
+
const line = process.platform === "win32" ? rawLine.replace(/\r$/, "") : rawLine;
|
|
83
93
|
if (line === "---") {
|
|
84
94
|
depth += 1;
|
|
85
95
|
continue;
|
|
@@ -154,10 +164,11 @@ function deriveWhen(trigger, rawTags, enforcementRaw) {
|
|
|
154
164
|
function writeAtomic(fileSystem, target, bytes) {
|
|
155
165
|
const temporary = path.join(path.dirname(target), `.migrate-policy-triggers.${process.pid}.${Math.random().toString(16).slice(2)}`);
|
|
156
166
|
// The shell writes through mktemp, whose private mode survives the rename on both platforms.
|
|
157
|
-
const mode = 0o600;
|
|
167
|
+
const mode = process.platform === "win32" ? undefined : 0o600;
|
|
158
168
|
try {
|
|
159
|
-
fileSystem.writeFileSync(temporary, bytes, { encoding: "utf8", mode, flag: "wx" });
|
|
160
|
-
|
|
169
|
+
fileSystem.writeFileSync(temporary, bytes, { encoding: "utf8", ...(mode === undefined ? {} : { mode }), flag: "wx" });
|
|
170
|
+
if (mode !== undefined)
|
|
171
|
+
fileSystem.chmodSync(temporary, mode);
|
|
161
172
|
fileSystem.renameSync(temporary, target);
|
|
162
173
|
return true;
|
|
163
174
|
}
|
|
@@ -447,7 +458,7 @@ export function runMigratePolicyTriggers(args, deps) {
|
|
|
447
458
|
continue;
|
|
448
459
|
}
|
|
449
460
|
const anchor = original.split("\n").some((line) => line.startsWith("trigger:")) ? /^trigger:/ : /^id:/;
|
|
450
|
-
const lines = original.split("\n");
|
|
461
|
+
const lines = original.split("\n").map((line) => process.platform === "win32" ? line.replace(/\r$/, "") : line);
|
|
451
462
|
let inserted = false;
|
|
452
463
|
const rewritten = lines.flatMap((line) => {
|
|
453
464
|
if (!inserted && anchor.test(line)) {
|
|
@@ -471,7 +482,10 @@ export function runMigratePolicyTriggers(args, deps) {
|
|
|
471
482
|
if (!dryRun && stateReady && !errorCode) {
|
|
472
483
|
const stampTmp = path.join(path.dirname(stamp), `.last-success.${process.pid}.${Math.random().toString(16).slice(2)}`);
|
|
473
484
|
try {
|
|
474
|
-
fileSystem.writeFileSync(stampTmp, `${nowSeconds()}\n`, {
|
|
485
|
+
fileSystem.writeFileSync(stampTmp, `${nowSeconds()}\n`, {
|
|
486
|
+
...(process.platform === "win32" ? {} : { mode: 0o600 }),
|
|
487
|
+
flag: "wx",
|
|
488
|
+
});
|
|
475
489
|
fileSystem.renameSync(stampTmp, stamp);
|
|
476
490
|
}
|
|
477
491
|
catch {
|