@harnessforce/cli 0.4.0 → 0.4.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.
Files changed (3) hide show
  1. package/README.md +33 -20
  2. package/dist/bin.js +474 -21
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -2,11 +2,11 @@
2
2
 
3
3
  `harnessforce`, the command line of [Harnessforce](https://github.com/lambda-script/harnessforce-agent). It
4
4
  connects a machine to a Harnessforce Workspace, supplies the ingest key to Claude Code's OpenTelemetry
5
- exporter, launches an agent linked to an Issue, and imports the metadata of past Claude Code sessions.
5
+ exporter, launches an agent linked to an Issue, and imports the metadata of past Claude Code and Codex sessions.
6
6
 
7
7
  It is used together with the `harnessforce` Claude Code plugin, whose `/harnessforce:setup` installs
8
- it. Public distribution on npm starts once the production domain of Harnessforce is decided; until
9
- then, install it from the [local marketplace build](https://github.com/lambda-script/harnessforce-agent/blob/main/docs/runbooks/local-marketplace.md).
8
+ it. It is published on npm. To connect to a staging or local Harnessforce instead, install it from the
9
+ [local marketplace build](https://github.com/lambda-script/harnessforce-agent/blob/main/docs/runbooks/local-marketplace.md).
10
10
 
11
11
  ## Requirements
12
12
 
@@ -30,7 +30,7 @@ harnessforce init
30
30
  | `harnessforce init [--port <port>] [--send-content] [--no-plugin-auto-update]` | Logs in with the browser, stores a user ingest key and an API token in the keychain, and configures Claude Code and Codex |
31
31
  | `harnessforce otel-headers` | Prints the `Authorization` header for Claude Code's `otelHeadersHelper`. Exits 3 when there is no key, 4 when the destination differs from the pinned origin, 5 while the CLI version is unsupported, and 1 on other failures. On Linux, after printing a key read from the kernel keyring, it moves that Workspace's items to an unlocked Secret Service (not when the hook starts it), so a reboot does not lose them |
32
32
  | `harnessforce run --issue <identifier> -- <agent> [args]` | Launches an agent with its session linked to an Issue |
33
- | `harnessforce import` | Sends the metadata of past Claude Code sessions in connected repositories |
33
+ | `harnessforce import` | Sends the metadata of past Claude Code and Codex sessions in connected repositories |
34
34
  | `harnessforce status [--json]` | Shows the connected Workspace, your role, the setup steps that remain with their URLs, and whether the `origin` repository of the current directory is connected to the GitHub App. Reads only; the remote URL is compared on this machine and never sent. Exits 0 whenever the status was read |
35
35
  | `harnessforce tune [--all] [--no-send] [--show-report] [--json]` | Analyzes past sessions on this machine by public rules and sends the counts (analysis reports) |
36
36
  | `harnessforce tune --report-only [--all]` | Analyzes and sends like `harnessforce tune`, but writes nothing to stdout. Meant for your OS scheduler |
@@ -117,7 +117,9 @@ tokens are sent only to the origins pinned by `harnessforce init`.
117
117
  1. It checks the OS keychain first: macOS Keychain or the Windows Credential Manager (through
118
118
  [`@napi-rs/keyring`](https://github.com/Brooooooklyn/keyring-node)). On Linux it writes and deletes
119
119
  a non-secret `availability-probe` item in the Secret Service (GNOME Keyring or KWallet), waiting at
120
- most 60 seconds for you to unlock it, and saves there when that works. Otherwise, as on a machine
120
+ most 60 seconds for you to unlock it, and saves there when that works. With KWallet it also says
121
+ which window asks for the password: KWallet shows one unlock window at a time, so an earlier
122
+ unanswered window behind other windows keeps the new one from appearing. Otherwise, as on a machine
121
123
  reached only over SSH, it saves to the kernel's persistent keyring, which lives in memory and is
122
124
  cleared on reboot or after a period of disuse; run `harnessforce init` again then. Without either
123
125
  store it stops. Keys are never written to a plain file.
@@ -137,7 +139,9 @@ tokens are sent only to the origins pinned by `harnessforce init`.
137
139
  them last, so a failure part way never pairs an old origin with a new key or token. After saving to
138
140
  the Secret Service it deletes the same Workspace's items from the kernel keyring. Later commands read
139
141
  a Workspace from the kernel keyring when it holds the ingest key there, and from the Secret Service
140
- otherwise.
142
+ otherwise. Only `harnessforce init`, `run` and `import` ask a locked Secret Service for an unlock;
143
+ hooks and `harnessforce otel-headers` check the lock over D-Bus first and treat a locked item as
144
+ missing.
141
145
  5. It updates the Claude Code user settings (`~/.claude/settings.json`, or `$CLAUDE_CONFIG_DIR`),
142
146
  changing only these keys:
143
147
  - `env`: the OTel exporter variables, `HARNESSFORCE_URL`, `HARNESSFORCE_ENDPOINT` and
@@ -153,7 +157,7 @@ tokens are sent only to the origins pinned by `harnessforce init`.
153
157
  - `[otel].exporter`: OTLP/HTTP (`protocol = "binary"`) to the ingest endpoint with
154
158
  `Authorization = "Bearer <user ingest key>"`. Codex cannot take the header from a command or expand
155
159
  `${VAR}`, so this is the one place a key is written to a plain file. It is the user ingest key only
156
- (never the API token), and you can revoke it on the Workspace's send keys page. `log_user_prompt` and
160
+ (never the API token), and you can revoke it in My ingest keys on your account page. `log_user_prompt` and
157
161
  `log_agent_responses` are set to `true` for a category you send with `--send-content` and the
158
162
  Workspace stores, and `false` otherwise
159
163
  - `[[hooks.SessionStart]]`: a handler that runs `harnessforce hook session-start`
@@ -170,12 +174,13 @@ tokens are sent only to the origins pinned by `harnessforce init`.
170
174
  `harnessforce otel-headers` prints `{"Authorization":"Bearer <key>"}`. The key is `HARNESSFORCE_INGEST_KEY` from
171
175
  the managed settings file when that is set, and otherwise the user key for
172
176
  `HARNESSFORCE_WORKSPACE_ID` from the keychain. If
173
- there is no key, it prints nothing and exits 1. The user key is printed only when every destination
177
+ there is no key, it prints nothing and exits 3. The user key is printed only when every destination
174
178
  variable that is set (`HARNESSFORCE_ENDPOINT`, `OTEL_EXPORTER_OTLP_ENDPOINT` and the per-signal
175
179
  `OTEL_EXPORTER_OTLP_*_ENDPOINT`) has the pinned origin. This stops a repository's
176
180
  `.claude/settings.json` `env` from pointing the hook at another host to collect the key. Otherwise it
177
- writes `harnessforce: user key withheld (destination not verified)` to stderr and exits 1. Run
178
- `harnessforce init` again to re-pin.
181
+ writes `harnessforce: user key withheld (destination not verified)` to stderr and exits 4. Run
182
+ `harnessforce init` again to re-pin. While the CLI version is unsupported it exits 5 without reading
183
+ the keychain, and any other failure (such as an unavailable keychain) exits 1.
179
184
 
180
185
  ### Refreshing the API token
181
186
 
@@ -242,15 +247,23 @@ It exits with the agent's exit code. Prompt and body logging are never turned on
242
247
 
243
248
  ## Importing past sessions (`harnessforce import`)
244
249
 
245
- `harnessforce import` sends metadata of past Claude Code sessions on this machine to Harnessforce, so that
246
- work done before the plugin was installed can be linked to issues too. Run `harnessforce init` first.
247
-
248
- - It reads the transcripts in `~/.claude/projects/<project>/<session>.jsonl` (or
249
- `$CLAUDE_CONFIG_DIR/projects`). Their format is not documented, so the parser is versioned
250
- (`parser_version`), and unreadable lines and files are skipped and counted.
251
- - Only these values are sent: session ID, first prompt ID, start and end time, repository, branch,
252
- the most used model, input and output token counts (without cache tokens), and the number of calls
253
- and failures per tool. Prompts, responses and tool inputs and outputs are never sent.
250
+ `harnessforce import` sends metadata of past Claude Code and Codex sessions on this machine to Harnessforce,
251
+ so that work done before the plugin was installed can be linked to issues too. Run `harnessforce init` first.
252
+
253
+ - For Claude Code it reads the transcripts in `~/.claude/projects/<project>/<session>.jsonl`, or under
254
+ the `CLAUDE_CONFIG_DIR` set in the `env` of the managed settings file or of `~/.claude/settings.json`.
255
+ The `CLAUDE_CONFIG_DIR` of the process environment is not read, because a repository's
256
+ `.claude/settings.json` can change it.
257
+ - For Codex it reads the rollouts in `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl`. `CODEX_HOME` is not
258
+ read, for the same reason. Compressed rollouts (`.jsonl.zst`) and `archived_sessions` are not read; the
259
+ compressed ones in the import window are counted and reported.
260
+ - The formats are not documented, so the parsers are versioned (`parser_version`), and unreadable lines
261
+ and files are skipped and counted.
262
+ - Only these values are sent: agent, session ID, start and end time, repository, branch, the most used
263
+ model, input and output token counts (without cache tokens), and the number of calls per tool. Claude
264
+ Code sessions also send the first prompt ID and the number of failures per tool. Codex sessions also
265
+ send the commit the session started on, and no failure counts, because a rollout does not keep whether
266
+ every call succeeded. Prompts, responses and tool inputs and outputs are never sent.
254
267
  - Only sessions whose `cwd` is in a repository connected to the Workspace are sent. The connected
255
268
  repositories come from `GET <HARNESSFORCE_URL>/api/v1/repositories` (all pages) and the import
256
269
  window from `session_import_days` (`GET <HARNESSFORCE_URL>/api/v1/workspace`), both with the API
@@ -286,7 +299,7 @@ role cannot use it.
286
299
  commands, paths and error text never leave the machine.
287
300
  - By default it analyzes sessions started in the last 30 days in connected repositories. `--all`
288
301
  analyzes every session, but still sends only sessions in connected repositories that started within
289
- `session_import_days`. Sessions without a Run are imported first, like `harnessforce import`.
302
+ `session_import_days`. Sessions that were not imported yet are imported first, like `harnessforce import`.
290
303
  - Sending is fail-open: when Harnessforce cannot be reached, the reports stay in
291
304
  `~/.harnessforce/tune/unsent.json` and are sent by the next run. `401` and `403` delete the unsent
292
305
  reports and ask you to run `harnessforce init`.
package/dist/bin.js CHANGED
@@ -1,12 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  import { createRequire } from "node:module";
3
+ import { access, chmod, lstat, mkdir, mkdtemp, open, readFile, readdir, realpath, rename, rm, stat, utimes, writeFile } from "node:fs/promises";
3
4
  import { constants, homedir, tmpdir } from "node:os";
4
5
  import { setTimeout as setTimeout$1 } from "node:timers/promises";
5
6
  import { basename, dirname, isAbsolute, join, posix, resolve, win32 } from "node:path";
6
7
  import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
7
- import { access, chmod, lstat, mkdir, mkdtemp, open, readFile, readdir, realpath, rename, rm, stat, utimes, writeFile } from "node:fs/promises";
8
8
  import { execFile, spawn } from "node:child_process";
9
9
  import { constants as constants$1, createReadStream } from "node:fs";
10
+ import { connect } from "node:net";
10
11
  import { createServer } from "node:http";
11
12
  import { ATTR, INTERVENTION_KINDS, LOOP_KINDS, MAX_WORK_LOCATIONS, PROPOSAL_KINDS, isToken, normalizeRepository } from "@harnessforce/semconv";
12
13
  import { createInterface } from "node:readline";
@@ -434,6 +435,7 @@ const INIT_MESSAGES = {
434
435
  contentNotStored: "This workspace does not store content, so sending content was not enabled. An owner or admin can turn it on under Collection & retention in the workspace settings, then run `harnessforce init --send-content` again",
435
436
  contentNotSent: "Content is not sent from this machine. Run `harnessforce init --send-content` to send it"
436
437
  };
438
+ const kwalletUnlockHint = (walletLabel = "kdewallet") => `KWallet asks for the password of the wallet "${walletLabel}" in a "KDE Wallet Service" window. If no window appears, an earlier request may be waiting behind other windows: answer that window, or log out and back in, then run \`harnessforce init\` again`;
437
439
  const codexNotes = (configPath) => [
438
440
  `Wrote the Codex configuration (${configPath}). Restart Codex to apply it`,
439
441
  "Codex hooks do not run until you trust them in Codex's /hooks. Trust the harnessforce SessionStart, PostToolUse, and Stop hooks in /hooks",
@@ -479,6 +481,7 @@ const apiTokenWorkspaceId = (account) => account.endsWith(API_TOKEN_SUFFIX) ? ac
479
481
  // src/credentials/linux-keychain.ts
480
482
  const SECRET_SERVICE_TIMEOUT_MS = 6e4;
481
483
  const WAITING_NOTICE_DELAY_MS = 2e3;
484
+ const PROVIDER_TIMEOUT_MS = 500;
482
485
  const PROBE_ACCOUNT$1 = "availability-probe";
483
486
  const PROBE_VALUE = "harnessforce";
484
487
  const MIGRATION_PROBE_TIMEOUT_MS = 200;
@@ -487,10 +490,11 @@ const CLEANUP_TIMEOUT_MS = 200;
487
490
  const succeeds = (operation) => operation().then(() => true, () => false);
488
491
  const workspaceOf = (account) => account.slice(0, account.lastIndexOf(":"));
489
492
  function createLinuxKeychain(options) {
490
- return build(options, SECRET_SERVICE_TIMEOUT_MS);
493
+ return build(options, SECRET_SERVICE_TIMEOUT_MS, true);
491
494
  }
492
- function build(options, timeoutMs) {
493
- const { kernel, secretService } = options;
495
+ function build(options, timeoutMs, allowsUnlockPrompt) {
496
+ const { kernel } = options;
497
+ const secretService = allowsUnlockPrompt ? options.secretService : options.secretService.withoutUnlockPrompt();
494
498
  const locations = /* @__PURE__ */ new Map();
495
499
  const locate = (workspaceId) => {
496
500
  let located = locations.get(workspaceId);
@@ -535,7 +539,7 @@ function build(options, timeoutMs) {
535
539
  if (location !== "kernel") return;
536
540
  await migrate(options, workspaceId).catch(() => {});
537
541
  },
538
- withTimeout: (ms) => build(options, ms),
542
+ withTimeout: (ms) => build(options, ms, false),
539
543
  openForInit: (init) => openForInit(options, timeoutMs, init)
540
544
  };
541
545
  }
@@ -544,8 +548,17 @@ async function openForInit({ kernel, secretService }, timeoutMs, { previousWorks
544
548
  await store.set(PROBE_ACCOUNT$1, PROBE_VALUE, timeoutMs);
545
549
  await store.delete(PROBE_ACCOUNT$1, timeoutMs);
546
550
  });
547
- const notice = setTimeout(onWaiting, WAITING_NOTICE_DELAY_MS);
548
- const usesSecretService = await canWrite(secretService).finally(() => clearTimeout(notice));
551
+ let isWaiting = true;
552
+ const notify = (provider) => {
553
+ if (isWaiting) onWaiting(provider);
554
+ };
555
+ const notice = setTimeout(() => {
556
+ secretService.provider(PROVIDER_TIMEOUT_MS).then(notify, () => notify(void 0));
557
+ }, WAITING_NOTICE_DELAY_MS);
558
+ const usesSecretService = await canWrite(secretService).finally(() => {
559
+ isWaiting = false;
560
+ clearTimeout(notice);
561
+ });
549
562
  const readKernel = async () => {
550
563
  if (!previousWorkspaceId) return [];
551
564
  const accounts = [ingestKeyAccount(previousWorkspaceId), apiTokenAccount(previousWorkspaceId)];
@@ -584,7 +597,8 @@ const MIGRATION_ORDER = (workspaceId) => [
584
597
  apiTokenAccount(workspaceId),
585
598
  ingestKeyAccount(workspaceId)
586
599
  ];
587
- async function migrate({ kernel, secretService }, workspaceId) {
600
+ async function migrate({ kernel, secretService: unlimited }, workspaceId) {
601
+ const secretService = unlimited.withoutUnlockPrompt();
588
602
  let isExpired = false;
589
603
  const deadline = Date.now() + MIGRATION_BUDGET_MS;
590
604
  const remaining = () => {
@@ -712,6 +726,419 @@ const unsupported = {
712
726
  withTimeout: () => unsupported,
713
727
  openForInit: async () => void 0
714
728
  };
729
+ // src/credentials/dbus.ts
730
+ const TYPES = [
731
+ "",
732
+ "method_call",
733
+ "method_return",
734
+ "error",
735
+ "signal"
736
+ ];
737
+ const FIELDS = {
738
+ path: [1, "o"],
739
+ interface: [2, "s"],
740
+ member: [3, "s"],
741
+ errorName: [4, "s"],
742
+ replySerial: [5, "u"],
743
+ destination: [6, "s"],
744
+ sender: [7, "s"],
745
+ signature: [8, "g"]
746
+ };
747
+ function parseSignature(signature) {
748
+ let index = 0;
749
+ const one = () => {
750
+ const code = signature[index++] ?? "";
751
+ if (code === "a") return {
752
+ code,
753
+ children: [one()]
754
+ };
755
+ if (code === "(" || code === "{") {
756
+ const close = code === "(" ? ")" : "}";
757
+ const children = [];
758
+ while (signature[index] !== close) {
759
+ if (index >= signature.length) throw new Error("bad signature");
760
+ children.push(one());
761
+ }
762
+ index++;
763
+ return {
764
+ code,
765
+ children
766
+ };
767
+ }
768
+ if (!"ybusogv".includes(code) || code === "") throw new Error(`unsupported D-Bus type ${code}`);
769
+ return {
770
+ code,
771
+ children: []
772
+ };
773
+ };
774
+ const types = [];
775
+ while (index < signature.length) types.push(one());
776
+ return types;
777
+ }
778
+ const alignmentOf = (type) => ({
779
+ y: 1,
780
+ g: 1,
781
+ v: 1,
782
+ b: 4,
783
+ u: 4,
784
+ s: 4,
785
+ o: 4,
786
+ a: 4
787
+ })[type.code] ?? 8;
788
+ var Writer = class {
789
+ bytes = [];
790
+ get length() {
791
+ return this.bytes.length;
792
+ }
793
+ align(n) {
794
+ while (this.bytes.length % n !== 0) this.bytes.push(0);
795
+ }
796
+ uint32(value) {
797
+ this.align(4);
798
+ const buffer = Buffer.alloc(4);
799
+ buffer.writeUInt32LE(value);
800
+ this.bytes.push(...buffer);
801
+ }
802
+ patchUint32(offset, value) {
803
+ const buffer = Buffer.alloc(4);
804
+ buffer.writeUInt32LE(value);
805
+ this.bytes.splice(offset, 4, ...buffer);
806
+ }
807
+ raw(values) {
808
+ this.bytes.push(...values);
809
+ }
810
+ write(type, value) {
811
+ const [child] = type.children;
812
+ switch (type.code) {
813
+ case "y":
814
+ this.raw([Number(value)]);
815
+ return;
816
+ case "b":
817
+ this.uint32(value ? 1 : 0);
818
+ return;
819
+ case "u":
820
+ this.uint32(Number(value));
821
+ return;
822
+ case "s":
823
+ case "o": {
824
+ const text = Buffer.from(String(value), "utf8");
825
+ this.uint32(text.length);
826
+ this.raw([...text, 0]);
827
+ return;
828
+ }
829
+ case "g": {
830
+ const text = Buffer.from(String(value), "utf8");
831
+ this.raw([
832
+ text.length,
833
+ ...text,
834
+ 0
835
+ ]);
836
+ return;
837
+ }
838
+ case "v": {
839
+ const variant = value;
840
+ this.write({
841
+ code: "g",
842
+ children: []
843
+ }, variant.signature);
844
+ const [inner] = parseSignature(variant.signature);
845
+ if (!inner) throw new Error("empty variant");
846
+ this.write(inner, variant.value);
847
+ return;
848
+ }
849
+ case "a": {
850
+ if (!child) throw new Error("bad array");
851
+ this.uint32(0);
852
+ const lengthAt = this.length - 4;
853
+ this.align(alignmentOf(child));
854
+ const start = this.length;
855
+ const elements = child.code === "{" ? Object.entries(value) : value;
856
+ for (const element of elements) this.write(child, element);
857
+ this.patchUint32(lengthAt, this.length - start);
858
+ return;
859
+ }
860
+ default: {
861
+ this.align(8);
862
+ const fields = value;
863
+ type.children.forEach((field, i) => {
864
+ this.write(field, fields[i]);
865
+ });
866
+ }
867
+ }
868
+ }
869
+ toBuffer() {
870
+ return Buffer.from(this.bytes);
871
+ }
872
+ };
873
+ var Reader = class {
874
+ offset = 0;
875
+ buffer;
876
+ isLittleEndian;
877
+ constructor(buffer, isLittleEndian) {
878
+ this.buffer = buffer;
879
+ this.isLittleEndian = isLittleEndian;
880
+ }
881
+ align(n) {
882
+ this.offset = Math.ceil(this.offset / n) * n;
883
+ }
884
+ uint32() {
885
+ this.align(4);
886
+ const value = this.isLittleEndian ? this.buffer.readUInt32LE(this.offset) : this.buffer.readUInt32BE(this.offset);
887
+ this.offset += 4;
888
+ return value;
889
+ }
890
+ read(type) {
891
+ const [child] = type.children;
892
+ switch (type.code) {
893
+ case "y": return this.buffer[this.offset++];
894
+ case "b": return this.uint32() !== 0;
895
+ case "u": return this.uint32();
896
+ case "s":
897
+ case "o": {
898
+ const length = this.uint32();
899
+ const text = this.buffer.toString("utf8", this.offset, this.offset + length);
900
+ this.offset += length + 1;
901
+ return text;
902
+ }
903
+ case "g": {
904
+ const length = this.buffer[this.offset++] ?? 0;
905
+ const text = this.buffer.toString("utf8", this.offset, this.offset + length);
906
+ this.offset += length + 1;
907
+ return text;
908
+ }
909
+ case "v": {
910
+ const signature = this.read({
911
+ code: "g",
912
+ children: []
913
+ });
914
+ const [inner] = parseSignature(signature);
915
+ if (!inner) throw new Error("empty variant");
916
+ return {
917
+ signature,
918
+ value: this.read(inner)
919
+ };
920
+ }
921
+ case "a": {
922
+ if (!child) throw new Error("bad array");
923
+ const length = this.uint32();
924
+ this.align(alignmentOf(child));
925
+ const end = this.offset + length;
926
+ const elements = [];
927
+ while (this.offset < end) elements.push(this.read(child));
928
+ return child.code === "{" ? Object.fromEntries(elements) : elements;
929
+ }
930
+ default:
931
+ this.align(8);
932
+ return type.children.map((field) => this.read(field));
933
+ }
934
+ }
935
+ };
936
+ const HEADER_FIELDS = parseSignature("a(yv)")[0];
937
+ function encodeMessage(message) {
938
+ const signature = message.signature ?? "";
939
+ const bodyWriter = new Writer();
940
+ parseSignature(signature).forEach((type, i) => {
941
+ bodyWriter.write(type, message.body?.[i]);
942
+ });
943
+ const body = bodyWriter.toBuffer();
944
+ const fields = [];
945
+ for (const [name, [code, fieldType]] of Object.entries(FIELDS)) {
946
+ const value = message[name];
947
+ if (value === void 0 || name === "signature" && value === "") continue;
948
+ fields.push([code, {
949
+ signature: fieldType,
950
+ value
951
+ }]);
952
+ }
953
+ const header = new Writer();
954
+ header.raw([
955
+ "l".charCodeAt(0),
956
+ TYPES.indexOf(message.type),
957
+ 0,
958
+ 1
959
+ ]);
960
+ header.uint32(body.length);
961
+ header.uint32(message.serial);
962
+ header.write(HEADER_FIELDS, fields);
963
+ header.align(8);
964
+ return Buffer.concat([header.toBuffer(), body]);
965
+ }
966
+ function decodeMessages(buffer) {
967
+ const messages = [];
968
+ let rest = buffer;
969
+ while (rest.length >= 16) {
970
+ const isLittleEndian = rest[0] === "l".charCodeAt(0);
971
+ const readUint32 = (offset) => isLittleEndian ? rest.readUInt32LE(offset) : rest.readUInt32BE(offset);
972
+ const bodyLength = readUint32(4);
973
+ const fieldsLength = readUint32(12);
974
+ const headerLength = Math.ceil((16 + fieldsLength) / 8) * 8;
975
+ const total = headerLength + bodyLength;
976
+ if (rest.length < total) break;
977
+ const reader = new Reader(rest.subarray(0, total), isLittleEndian);
978
+ reader.offset = 8;
979
+ const serial = reader.uint32();
980
+ const fields = reader.read(HEADER_FIELDS);
981
+ const message = {
982
+ type: TYPES[rest[1] ?? 0] || "signal",
983
+ serial
984
+ };
985
+ for (const [code, { value }] of fields) {
986
+ const name = Object.entries(FIELDS).find(([, [c]]) => c === code)?.[0];
987
+ if (name) Object.assign(message, { [name]: value });
988
+ }
989
+ reader.offset = headerLength;
990
+ message.body = parseSignature(message.signature ?? "").map((type) => reader.read(type));
991
+ messages.push(message);
992
+ rest = rest.subarray(total);
993
+ }
994
+ return {
995
+ messages,
996
+ rest
997
+ };
998
+ }
999
+ function socketPath(address) {
1000
+ for (const entry of address.split(";")) {
1001
+ const [transport, rest = ""] = entry.split(/:(.*)/s);
1002
+ if (transport !== "unix") continue;
1003
+ const params = new Map(rest.split(",").map((pair) => pair.split(/=(.*)/s)));
1004
+ const path = params.get("path");
1005
+ if (path) return decodeURIComponent(path);
1006
+ const abstract = params.get("abstract");
1007
+ if (abstract) return `\0${decodeURIComponent(abstract)}`;
1008
+ }
1009
+ }
1010
+ function openBus(address, uid, timeoutMs) {
1011
+ const path = socketPath(address);
1012
+ if (!path) return Promise.reject(/* @__PURE__ */ new Error("unsupported D-Bus address"));
1013
+ return new Promise((resolve, reject) => {
1014
+ const socket = connect({ path });
1015
+ const waiting = /* @__PURE__ */ new Map();
1016
+ let serial = 0;
1017
+ let pending = Buffer.alloc(0);
1018
+ let isAuthenticated = false;
1019
+ const fail = (error) => {
1020
+ socket.destroy();
1021
+ reject(error);
1022
+ for (const call of waiting.values()) call.reject(error);
1023
+ waiting.clear();
1024
+ };
1025
+ const timer = setTimeout(() => fail(/* @__PURE__ */ new Error("D-Bus timed out")), timeoutMs);
1026
+ const close = () => {
1027
+ clearTimeout(timer);
1028
+ socket.destroy();
1029
+ };
1030
+ const call = (request) => new Promise((resolveCall, rejectCall) => {
1031
+ serial += 1;
1032
+ waiting.set(serial, {
1033
+ resolve: resolveCall,
1034
+ reject: rejectCall
1035
+ });
1036
+ socket.write(encodeMessage({
1037
+ type: "method_call",
1038
+ serial,
1039
+ ...request
1040
+ }));
1041
+ });
1042
+ socket.on("error", fail);
1043
+ socket.on("close", () => fail(/* @__PURE__ */ new Error("D-Bus connection closed")));
1044
+ socket.on("connect", () => {
1045
+ const uidHex = Buffer.from(String(uid)).toString("hex");
1046
+ socket.write(`\0AUTH EXTERNAL ${uidHex}\r\n`);
1047
+ });
1048
+ socket.on("data", (chunk) => {
1049
+ pending = Buffer.concat([pending, chunk]);
1050
+ if (!isAuthenticated) {
1051
+ const lineEnd = pending.indexOf("\r\n");
1052
+ if (lineEnd < 0) return;
1053
+ const line = pending.toString("latin1", 0, lineEnd);
1054
+ pending = pending.subarray(lineEnd + 2);
1055
+ if (!line.startsWith("OK ")) return fail(/* @__PURE__ */ new Error("D-Bus authentication failed"));
1056
+ isAuthenticated = true;
1057
+ socket.write("BEGIN\r\n");
1058
+ call({
1059
+ destination: "org.freedesktop.DBus",
1060
+ path: "/org/freedesktop/DBus",
1061
+ interface: "org.freedesktop.DBus",
1062
+ member: "Hello"
1063
+ }).then(() => resolve({
1064
+ call,
1065
+ close
1066
+ }), fail);
1067
+ }
1068
+ const { messages, rest } = decodeMessages(pending);
1069
+ pending = rest;
1070
+ for (const message of messages) {
1071
+ const replied = message.replySerial === void 0 ? void 0 : waiting.get(message.replySerial);
1072
+ if (!replied || message.replySerial === void 0) continue;
1073
+ waiting.delete(message.replySerial);
1074
+ if (message.type === "error") replied.reject(new Error(message.errorName ?? "D-Bus error"));
1075
+ else replied.resolve(message.body ?? []);
1076
+ }
1077
+ });
1078
+ });
1079
+ }
1080
+ // src/credentials/secret-service-lock.ts
1081
+ const SECRETS = "org.freedesktop.secrets";
1082
+ const SERVICE_PATH = "/org/freedesktop/secrets";
1083
+ const DEFAULT_COLLECTION = "/org/freedesktop/secrets/aliases/default";
1084
+ const KWALLET_PROCESS = /^(ksecretd|kwalletd\d*)$/;
1085
+ function sessionBusAddress(env) {
1086
+ if (env.DBUS_SESSION_BUS_ADDRESS) return env.DBUS_SESSION_BUS_ADDRESS;
1087
+ if (env.XDG_RUNTIME_DIR) return `unix:path=${env.XDG_RUNTIME_DIR}/bus`;
1088
+ }
1089
+ const property = (bus, name) => bus.call({
1090
+ destination: SECRETS,
1091
+ path: DEFAULT_COLLECTION,
1092
+ interface: "org.freedesktop.DBus.Properties",
1093
+ member: "Get",
1094
+ signature: "ss",
1095
+ body: ["org.freedesktop.Secret.Collection", name]
1096
+ }).then(([variant]) => variant.value);
1097
+ function createSecretServiceLock({ address, uid, readComm }) {
1098
+ const withBus = async (timeoutMs, use) => {
1099
+ if (!address) throw new Error("no D-Bus session bus");
1100
+ const bus = await openBus(address, uid, timeoutMs);
1101
+ try {
1102
+ return await use(bus);
1103
+ } finally {
1104
+ bus.close();
1105
+ }
1106
+ };
1107
+ return {
1108
+ needsUnlock: (operation, account, timeoutMs) => withBus(timeoutMs, async (bus) => {
1109
+ const [unlocked, locked] = await bus.call({
1110
+ destination: SECRETS,
1111
+ path: SERVICE_PATH,
1112
+ interface: "org.freedesktop.Secret.Service",
1113
+ member: "SearchItems",
1114
+ signature: "a{ss}",
1115
+ body: [{
1116
+ service: KEYCHAIN_SERVICE,
1117
+ username: account
1118
+ }]
1119
+ });
1120
+ if (locked.length > 0) return true;
1121
+ if (unlocked.length > 0 || operation !== "set") return false;
1122
+ return property(bus, "Locked").then((isLocked) => isLocked === true, () => true);
1123
+ }),
1124
+ provider: (timeoutMs) => withBus(timeoutMs, async (bus) => {
1125
+ const [pid] = await bus.call({
1126
+ destination: "org.freedesktop.DBus",
1127
+ path: "/org/freedesktop/DBus",
1128
+ interface: "org.freedesktop.DBus",
1129
+ member: "GetConnectionUnixProcessID",
1130
+ signature: "s",
1131
+ body: [SECRETS]
1132
+ });
1133
+ const comm = (await readComm(pid)).trim();
1134
+ const label = await property(bus, "Label").catch(() => void 0);
1135
+ return {
1136
+ isKWallet: KWALLET_PROCESS.test(comm),
1137
+ walletLabel: typeof label === "string" ? label : void 0
1138
+ };
1139
+ })
1140
+ };
1141
+ }
715
1142
  // src/credentials/secret-service-process.ts
716
1143
  const CHILD_SCRIPT = `
717
1144
  const [op, service, account, timeoutMs, modulePath] = process.argv.slice(1);
@@ -737,7 +1164,7 @@ process.stdin.on("end", async () => {
737
1164
  }
738
1165
  });
739
1166
  `;
740
- function createSecretServiceProcess({ execPath, resolveKeyring }) {
1167
+ function createSecretServiceProcess({ execPath, resolveKeyring, lock }) {
741
1168
  const run = (op, account, timeoutMs, input = "") => new Promise((resolve, reject) => {
742
1169
  const child = spawn(execPath, [
743
1170
  "-e",
@@ -768,17 +1195,29 @@ function createSecretServiceProcess({ execPath, resolveKeyring }) {
768
1195
  child.stdin.on("error", () => {});
769
1196
  child.stdin.end(input);
770
1197
  });
771
- return {
1198
+ const operations = (perform) => ({
772
1199
  async get(account, timeoutMs) {
773
- const secret = await run("get", account, timeoutMs);
1200
+ const secret = await perform("get", account, timeoutMs);
774
1201
  return typeof secret === "string" ? secret : void 0;
775
1202
  },
776
1203
  async set(account, secret, timeoutMs) {
777
- await run("set", account, timeoutMs, secret);
1204
+ await perform("set", account, timeoutMs, secret);
778
1205
  },
779
1206
  async delete(account, timeoutMs) {
780
- await run("delete", account, timeoutMs);
781
- },
1207
+ await perform("delete", account, timeoutMs);
1208
+ }
1209
+ });
1210
+ const withoutPrompt = async (op, account, timeoutMs, input) => {
1211
+ const startedAt = Date.now();
1212
+ if (await lock.needsUnlock(op, account, timeoutMs)) throw new Error("the Secret Service is locked");
1213
+ const remainingMs = timeoutMs - (Date.now() - startedAt);
1214
+ if (remainingMs <= 0) throw new Error("Secret Service timed out");
1215
+ return run(op, account, remainingMs, input);
1216
+ };
1217
+ return {
1218
+ ...operations(run),
1219
+ withoutUnlockPrompt: () => operations(withoutPrompt),
1220
+ provider: (timeoutMs) => lock.provider(timeoutMs),
782
1221
  async list(timeoutMs) {
783
1222
  return (await run("list", "", timeoutMs)).map(({ account, password }) => ({
784
1223
  account,
@@ -1432,8 +1871,10 @@ What it does:
1432
1871
  and what to do next.
1433
1872
 
1434
1873
  Sends:
1435
- Per session: agent, session ID, repository, branch, commit, start and end
1436
- time, model, input and output token counts, and tool call counts.
1874
+ Per session: agent, session ID, repository, branch, start and end time,
1875
+ model, input and output token counts, and tool call counts. Claude Code
1876
+ sessions also send the first prompt ID; Codex sessions also send the commit
1877
+ the session started on.
1437
1878
 
1438
1879
  Does not send:
1439
1880
  Prompt text, model responses, tool input or output, and code diffs. Sessions
@@ -1498,8 +1939,12 @@ Does not send:
1498
1939
 
1499
1940
  Exit codes:
1500
1941
  0 Header printed.
1501
- 1 No usable key (not logged in, keychain unavailable, or the destination was
1502
- not verified). Nothing is printed to stdout.
1942
+ 3 No key for the Workspace (not logged in, or the keychain item is gone, for
1943
+ example after a reboot cleared the kernel keyring).
1944
+ 4 The destination was not verified: it differs from the origin init pinned.
1945
+ 5 This CLI version is unsupported. The keychain is not read.
1946
+ 1 Any other failure (keychain unavailable, read failed, or timed out).
1947
+ Nothing is printed to stdout unless the exit code is 0.
1503
1948
 
1504
1949
  Examples:
1505
1950
  harnessforce otel-headers
@@ -1717,7 +2162,7 @@ const DESTINATION_MISMATCH_WARNING = {
1717
2162
  // ../agent-core/src/issue.ts
1718
2163
  const isIssueIdentifier = (value) => isToken(value, 300);
1719
2164
  // ../agent-core/src/using-harnessforce.ts
1720
- const usingHarnessforceBody = "---\nname: using-harnessforce\ndescription: Use when starting a session in an environment with the Harnessforce hook, when starting work on an Issue, or when asked what is recorded or collected. The hook injects this text into the context at session start.\n---\n\n# Using Harnessforce\n\nThis session has the Harnessforce hook installed. Harnessforce links the work of people and AI to Issues and records it. Follow these rules.\n\n## What is recorded\n\n- Session registration: agent, session ID, repository, branch, commit, start time, and the first prompt ID.\n- Work locations: the repository and branch of each Git working tree the session works in. Paths are not sent.\n- Config snapshot: the structure of the configuration in use (Claude Code only).\n- OTel telemetry: what the user chooses to send.\n- For work on an Issue, `record-run` registers the session as a Run and closes it. It records no Plan, Decision, or Definition of Done.\n\n## What is not collected\n\n- Prompts, responses, tool input and output, and the body of code diffs arrive only when the user sends them (`--send-content` or managed settings) and the Workspace stores that category. The members the Workspace chooses can read them. `harnessforce import` never sends them.\n- Never put these contents, secrets, or environment variable values in MCP tool input or in a record.\n\n## Which hook sends what\n\n- SessionStart: session registration and the config snapshot (Codex sends session registration only).\n- UserPromptSubmit: resends on the first prompt (Claude Code only).\n- Stop: sends the work locations at the end of a turn in which they changed.\n- A failing hook never stops the session. When the hook cannot send, it shows a one-line warning. Tell the user when a warning appears.\n\n## Which skill and tool to use, and when\n\n- For work on a specified Issue ID, or when the environment variable `HARNESSFORCE_ISSUE` is set, follow `record-run` and use `start_run`, `get_issue`, and `complete_run`. An agent that cannot load the skill calls the tools in the same order.\n- Pass the value of the `harnessforce session_id:` line in the context to `start_run`. If there are several, use the last one. If the line is missing, do not guess the session ID, and do not call `start_run` or `complete_run`.\n- Use `propose-improvements` for the analysis of `/harnessforce:tune`.\n- When no Issue is specified, do not start recording a Run.\n\n## Red Flags\n\nStop when one of these thoughts comes up.\n\n| Thought | Reality |\n| --- | --- |\n| An Issue was specified, but the work is small, so recording is unnecessary | Work on a specified Issue is recorded regardless of its size |\n| The session ID line is missing, so I will guess it and call `start_run` | Do not guess. The Run would no longer be linked to the session |\n| Summarizing the content before recording would help | Do not pass content to the tool. It goes beyond what is collected |\n| A warning appeared, but I can ignore it | Sending is not working. Tell the user |\n\n## Priority\n\n1. The user's instructions (CLAUDE.md, AGENTS.md, direct requests). If the user tells you to stop recording, stop recording the Run.\n2. This introduction.\n3. Default behavior.\n\n## Subagents\n\nA subagent that was delegated only a specific task by its parent does not register a Run by following this introduction. The parent registers the Run.\n".replace(/\r\n/g, "\n").replace(/^---\n[\s\S]*?\n---\n+/, "").trimEnd();
2165
+ const usingHarnessforceBody = "---\nname: using-harnessforce\ndescription: Use when starting a session in an environment with the Harnessforce hook, when starting work on an Issue, or when asked what is recorded or collected. The hook injects this text into the context at session start.\n---\n\n# Using Harnessforce\n\nThis session has the Harnessforce hook installed. Harnessforce links the work of people and AI to Issues and records it. Follow these rules.\n\n## What is recorded\n\n- Session registration: agent, session ID, repository, branch, commit, start time, and the first prompt ID.\n- Work locations: the repository and branch of each Git working tree the session works in. Paths are not sent.\n- Config snapshot: the structure of the configuration in use (Claude Code only).\n- OTel telemetry: what the user chooses to send.\n- For work on an Issue, `record-run` registers the session as a Run and closes it. It records no Plan, Decision, or Definition of Done.\n\n## What is not collected\n\n- Prompts, responses, tool input and output, and the body of code diffs arrive only when the user sends them (`--send-content` or managed settings) and the Workspace stores that category. The members the Workspace chooses can read them. `harnessforce import` never sends them.\n- Never put these contents, secrets, or environment variable values in MCP tool input or in a record.\n\n## Which hook sends what\n\n- SessionStart: session registration and the config snapshot (Codex sends session registration only).\n- UserPromptSubmit: resends on the first prompt (Claude Code only).\n- Stop: sends the work locations at the end of a turn in which they changed.\n- A failing hook never stops the session. When the hook cannot send, it shows a one-line warning. Tell the user when a warning appears.\n\n## Which skill and tool to use, and when\n\n- For work on a specified Issue ID, or when the environment variable `HARNESSFORCE_ISSUE` is set, follow `record-run` and use `start_run`, `get_issue`, and `complete_run`. An agent that cannot load the skill calls the tools in the same order.\n- Pass the value of the `harnessforce session_id:` line in the context to `start_run`. If there are several, use the last one. If the line is missing, do not guess the session ID, and do not call `start_run` or `complete_run`.\n- Use `propose-improvements` for the analysis of `/harnessforce:tune`.\n- When no Issue is specified, do not start recording a Run on your own. For work that commits changes, follow \"When no Issue is specified\" in `record-run` and ask the user once whether to link it to an existing Issue.\n\n## Red Flags\n\nStop when one of these thoughts comes up.\n\n| Thought | Reality |\n| --- | --- |\n| An Issue was specified, but the work is small, so recording is unnecessary | Work on a specified Issue is recorded regardless of its size |\n| The session ID line is missing, so I will guess it and call `start_run` | Do not guess. The Run would no longer be linked to the session |\n| Summarizing the content before recording would help | Do not pass content to the tool. It goes beyond what is collected |\n| A warning appeared, but I can ignore it | Sending is not working. Tell the user |\n\n## Priority\n\n1. The user's instructions (CLAUDE.md, AGENTS.md, direct requests). If the user tells you to stop recording, stop recording the Run.\n2. This introduction.\n3. Default behavior.\n\n## Subagents\n\nA subagent that was delegated only a specific task by its parent does not register a Run by following this introduction. The parent registers the Run.\n".replace(/\r\n/g, "\n").replace(/^---\n[\s\S]*?\n---\n+/, "").trimEnd();
1721
2166
  // ../agent-core/src/session-context.ts
1722
2167
  const sessionContextLine = (sessionId) => `harnessforce session_id: ${sessionId}`;
1723
2168
  const sessionContext = (sessionId) => ({ hookSpecificOutput: {
@@ -10151,7 +10596,10 @@ function finishInit(issued, args, deps, result) {
10151
10596
  async function openKeychain(deps, previousWorkspaceId) {
10152
10597
  return await deps.keychain.openForInit({
10153
10598
  previousWorkspaceId,
10154
- onWaiting: () => deps.stderr(`${INIT_MESSAGES.waitingForSecretService}\n`)
10599
+ onWaiting: (provider) => {
10600
+ deps.stderr(`${INIT_MESSAGES.waitingForSecretService}\n`);
10601
+ if (provider?.isKWallet) deps.stderr(`${kwalletUnlockHint(provider.walletLabel)}\n`);
10602
+ }
10155
10603
  }).catch(() => stop$1("keychainUnavailable")) ?? stopWith(keychainUnavailableMessage(deps.keychain));
10156
10604
  }
10157
10605
  function revokeHashes({ items }) {
@@ -13706,7 +14154,12 @@ process.exitCode = relaunched ?? await run(argv, {
13706
14154
  load: () => import("@napi-rs/keyring"),
13707
14155
  secretService: createSecretServiceProcess({
13708
14156
  execPath: process.execPath,
13709
- resolveKeyring: () => require.resolve("@napi-rs/keyring")
14157
+ resolveKeyring: () => require.resolve("@napi-rs/keyring"),
14158
+ lock: createSecretServiceLock({
14159
+ address: sessionBusAddress(process.env),
14160
+ uid: process.getuid?.() ?? 0,
14161
+ readComm: (pid) => readFile(`/proc/${pid}/comm`, "utf8")
14162
+ })
13710
14163
  })
13711
14164
  }),
13712
14165
  stdout: (text) => process.stdout.write(text),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harnessforce/cli",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "Harnessforce command line (harnessforce)",
5
5
  "keywords": [
6
6
  "harnessforce",