@geminixiang/mama 0.2.0-beta.7 → 0.2.0-beta.9

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 (140) hide show
  1. package/README.md +3 -5
  2. package/dist/adapter.d.ts +2 -2
  3. package/dist/adapter.d.ts.map +1 -1
  4. package/dist/adapter.js.map +1 -1
  5. package/dist/adapters/discord/bot.d.ts +1 -0
  6. package/dist/adapters/discord/bot.d.ts.map +1 -1
  7. package/dist/adapters/discord/bot.js +7 -4
  8. package/dist/adapters/discord/bot.js.map +1 -1
  9. package/dist/adapters/discord/context.d.ts +1 -1
  10. package/dist/adapters/discord/context.d.ts.map +1 -1
  11. package/dist/adapters/discord/context.js +1 -2
  12. package/dist/adapters/discord/context.js.map +1 -1
  13. package/dist/adapters/slack/bot.d.ts.map +1 -1
  14. package/dist/adapters/slack/bot.js +20 -6
  15. package/dist/adapters/slack/bot.js.map +1 -1
  16. package/dist/adapters/slack/branch-manager.d.ts +1 -0
  17. package/dist/adapters/slack/branch-manager.d.ts.map +1 -1
  18. package/dist/adapters/slack/branch-manager.js +9 -8
  19. package/dist/adapters/slack/branch-manager.js.map +1 -1
  20. package/dist/adapters/slack/context.d.ts +1 -1
  21. package/dist/adapters/slack/context.d.ts.map +1 -1
  22. package/dist/adapters/slack/context.js +10 -13
  23. package/dist/adapters/slack/context.js.map +1 -1
  24. package/dist/adapters/telegram/bot.d.ts.map +1 -1
  25. package/dist/adapters/telegram/bot.js +2 -2
  26. package/dist/adapters/telegram/bot.js.map +1 -1
  27. package/dist/agent.d.ts +1 -2
  28. package/dist/agent.d.ts.map +1 -1
  29. package/dist/agent.js +477 -408
  30. package/dist/agent.js.map +1 -1
  31. package/dist/commands/login.d.ts.map +1 -1
  32. package/dist/commands/login.js +41 -2
  33. package/dist/commands/login.js.map +1 -1
  34. package/dist/commands/new.d.ts.map +1 -1
  35. package/dist/commands/new.js +1 -1
  36. package/dist/commands/new.js.map +1 -1
  37. package/dist/commands/sandbox.d.ts +1 -1
  38. package/dist/commands/sandbox.d.ts.map +1 -1
  39. package/dist/commands/sandbox.js +25 -2
  40. package/dist/commands/sandbox.js.map +1 -1
  41. package/dist/commands/session-view.d.ts.map +1 -1
  42. package/dist/commands/session-view.js +5 -1
  43. package/dist/commands/session-view.js.map +1 -1
  44. package/dist/commands/types.d.ts +1 -3
  45. package/dist/commands/types.d.ts.map +1 -1
  46. package/dist/commands/types.js.map +1 -1
  47. package/dist/config.d.ts +4 -0
  48. package/dist/config.d.ts.map +1 -1
  49. package/dist/config.js +35 -23
  50. package/dist/config.js.map +1 -1
  51. package/dist/context.d.ts +2 -44
  52. package/dist/context.d.ts.map +1 -1
  53. package/dist/context.js +7 -225
  54. package/dist/context.js.map +1 -1
  55. package/dist/events.d.ts.map +1 -1
  56. package/dist/events.js +15 -14
  57. package/dist/events.js.map +1 -1
  58. package/dist/execution-resolver.d.ts +3 -2
  59. package/dist/execution-resolver.d.ts.map +1 -1
  60. package/dist/execution-resolver.js +40 -7
  61. package/dist/execution-resolver.js.map +1 -1
  62. package/dist/file-guards.d.ts +6 -0
  63. package/dist/file-guards.d.ts.map +1 -0
  64. package/dist/file-guards.js +48 -0
  65. package/dist/file-guards.js.map +1 -0
  66. package/dist/log.d.ts +1 -5
  67. package/dist/log.d.ts.map +1 -1
  68. package/dist/log.js +13 -38
  69. package/dist/log.js.map +1 -1
  70. package/dist/login/index.d.ts +14 -2
  71. package/dist/login/index.d.ts.map +1 -1
  72. package/dist/login/index.js +40 -13
  73. package/dist/login/index.js.map +1 -1
  74. package/dist/login/portal.d.ts +2 -1
  75. package/dist/login/portal.d.ts.map +1 -1
  76. package/dist/login/portal.js +12 -12
  77. package/dist/login/portal.js.map +1 -1
  78. package/dist/main.d.ts.map +1 -1
  79. package/dist/main.js +26 -27
  80. package/dist/main.js.map +1 -1
  81. package/dist/provisioner.d.ts +0 -2
  82. package/dist/provisioner.d.ts.map +1 -1
  83. package/dist/provisioner.js +2 -4
  84. package/dist/provisioner.js.map +1 -1
  85. package/dist/runtime/conversation-orchestrator.d.ts +42 -0
  86. package/dist/runtime/conversation-orchestrator.d.ts.map +1 -0
  87. package/dist/runtime/conversation-orchestrator.js +150 -0
  88. package/dist/runtime/conversation-orchestrator.js.map +1 -0
  89. package/dist/runtime/session-runtime.d.ts +1 -1
  90. package/dist/runtime/session-runtime.d.ts.map +1 -1
  91. package/dist/runtime/session-runtime.js +49 -148
  92. package/dist/runtime/session-runtime.js.map +1 -1
  93. package/dist/sandbox/cloudflare.d.ts.map +1 -1
  94. package/dist/sandbox/cloudflare.js +2 -2
  95. package/dist/sandbox/cloudflare.js.map +1 -1
  96. package/dist/sandbox/container.d.ts.map +1 -1
  97. package/dist/sandbox/container.js +1 -1
  98. package/dist/sandbox/container.js.map +1 -1
  99. package/dist/sandbox/index.d.ts.map +1 -1
  100. package/dist/sandbox/index.js +4 -4
  101. package/dist/sandbox/index.js.map +1 -1
  102. package/dist/sentry.d.ts +1 -1
  103. package/dist/sentry.d.ts.map +1 -1
  104. package/dist/sentry.js +2 -2
  105. package/dist/sentry.js.map +1 -1
  106. package/dist/session-store.d.ts +1 -0
  107. package/dist/session-store.d.ts.map +1 -1
  108. package/dist/session-store.js +18 -14
  109. package/dist/session-store.js.map +1 -1
  110. package/dist/session-view/portal.d.ts +6 -1
  111. package/dist/session-view/portal.d.ts.map +1 -1
  112. package/dist/session-view/portal.js +1027 -89
  113. package/dist/session-view/portal.js.map +1 -1
  114. package/dist/session-view/service.d.ts.map +1 -1
  115. package/dist/session-view/service.js +4 -3
  116. package/dist/session-view/service.js.map +1 -1
  117. package/dist/session-view/store.d.ts +2 -1
  118. package/dist/session-view/store.d.ts.map +1 -1
  119. package/dist/session-view/store.js +2 -1
  120. package/dist/session-view/store.js.map +1 -1
  121. package/dist/store.d.ts.map +1 -1
  122. package/dist/store.js +7 -13
  123. package/dist/store.js.map +1 -1
  124. package/dist/tool-diagnostics.d.ts +2 -0
  125. package/dist/tool-diagnostics.d.ts.map +1 -0
  126. package/dist/tool-diagnostics.js +7 -0
  127. package/dist/tool-diagnostics.js.map +1 -0
  128. package/dist/vault-routing.d.ts +0 -3
  129. package/dist/vault-routing.d.ts.map +1 -1
  130. package/dist/vault-routing.js +0 -24
  131. package/dist/vault-routing.js.map +1 -1
  132. package/dist/vault.d.ts +21 -57
  133. package/dist/vault.d.ts.map +1 -1
  134. package/dist/vault.js +114 -246
  135. package/dist/vault.js.map +1 -1
  136. package/package.json +3 -1
  137. package/dist/bindings.d.ts +0 -45
  138. package/dist/bindings.d.ts.map +0 -1
  139. package/dist/bindings.js +0 -75
  140. package/dist/bindings.js.map +0 -1
@@ -1 +1 @@
1
- {"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../../src/session-view/store.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AAElD,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,YAAY,CAAC;IACvB,cAAc,EAAE,MAAM,CAAC;IACvB,cAAc,EAAE,MAAM,CAAC;IACvB,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,EAAE,MAAM,CAAC;CACnB;AAID,qBAAa,6BAA6B;IACxC,OAAO,CAAC,MAAM,CAAuC;IAErD,MAAM,CACJ,QAAQ,EAAE,YAAY,EACtB,cAAc,EAAE,MAAM,EACtB,cAAc,EAAE,MAAM,EACtB,UAAU,EAAE,MAAM,EAClB,WAAW,EAAE,MAAM,GAClB,gBAAgB,CAYlB;IAED,IAAI,CAAC,QAAQ,EAAE,MAAM,GAAG,gBAAgB,GAAG,SAAS,CAOnD;IAED,KAAK,IAAI,IAAI,CAOZ;CACF","sourcesContent":["import { randomBytes } from \"crypto\";\nimport type { PlatformName } from \"../adapter.js\";\n\nexport interface SessionViewToken {\n token: string;\n platform: PlatformName;\n platformUserId: string;\n conversationId: string;\n sessionKey: string;\n sessionFile: string;\n expiresAt: number;\n}\n\nconst TTL_MS = 24 * 60 * 60 * 1000;\n\nexport class InMemorySessionViewTokenStore {\n private tokens = new Map<string, SessionViewToken>();\n\n create(\n platform: PlatformName,\n platformUserId: string,\n conversationId: string,\n sessionKey: string,\n sessionFile: string,\n ): SessionViewToken {\n const token: SessionViewToken = {\n token: randomBytes(16).toString(\"hex\"),\n platform,\n platformUserId,\n conversationId,\n sessionKey,\n sessionFile,\n expiresAt: Date.now() + TTL_MS,\n };\n this.tokens.set(token.token, token);\n return token;\n }\n\n peek(rawToken: string): SessionViewToken | undefined {\n const entry = this.tokens.get(rawToken);\n if (!entry || Date.now() > entry.expiresAt) {\n if (entry) this.tokens.delete(rawToken);\n return undefined;\n }\n return entry;\n }\n\n purge(): void {\n const now = Date.now();\n for (const [key, value] of this.tokens) {\n if (now > value.expiresAt) {\n this.tokens.delete(key);\n }\n }\n }\n}\n"]}
1
+ {"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../../src/session-view/store.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AAElD,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,YAAY,CAAC;IACvB,cAAc,EAAE,MAAM,CAAC;IACvB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,cAAc,EAAE,MAAM,CAAC;IACvB,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,EAAE,MAAM,CAAC;CACnB;AAID,qBAAa,6BAA6B;IACxC,OAAO,CAAC,MAAM,CAAuC;IAErD,MAAM,CACJ,QAAQ,EAAE,YAAY,EACtB,cAAc,EAAE,MAAM,EACtB,cAAc,EAAE,MAAM,EACtB,UAAU,EAAE,MAAM,EAClB,WAAW,EAAE,MAAM,EACnB,gBAAgB,CAAC,EAAE,MAAM,GACxB,gBAAgB,CAalB;IAED,IAAI,CAAC,QAAQ,EAAE,MAAM,GAAG,gBAAgB,GAAG,SAAS,CAOnD;IAED,KAAK,IAAI,IAAI,CAOZ;CACF","sourcesContent":["import { randomBytes } from \"crypto\";\nimport type { PlatformName } from \"../adapter.js\";\n\nexport interface SessionViewToken {\n token: string;\n platform: PlatformName;\n platformUserId: string;\n platformUserName?: string;\n conversationId: string;\n sessionKey: string;\n sessionFile: string;\n expiresAt: number;\n}\n\nconst TTL_MS = 24 * 60 * 60 * 1000;\n\nexport class InMemorySessionViewTokenStore {\n private tokens = new Map<string, SessionViewToken>();\n\n create(\n platform: PlatformName,\n platformUserId: string,\n conversationId: string,\n sessionKey: string,\n sessionFile: string,\n platformUserName?: string,\n ): SessionViewToken {\n const token: SessionViewToken = {\n token: randomBytes(16).toString(\"hex\"),\n platform,\n platformUserId,\n ...(platformUserName ? { platformUserName } : {}),\n conversationId,\n sessionKey,\n sessionFile,\n expiresAt: Date.now() + TTL_MS,\n };\n this.tokens.set(token.token, token);\n return token;\n }\n\n peek(rawToken: string): SessionViewToken | undefined {\n const entry = this.tokens.get(rawToken);\n if (!entry || Date.now() > entry.expiresAt) {\n if (entry) this.tokens.delete(rawToken);\n return undefined;\n }\n return entry;\n }\n\n purge(): void {\n const now = Date.now();\n for (const [key, value] of this.tokens) {\n if (now > value.expiresAt) {\n this.tokens.delete(key);\n }\n }\n }\n}\n"]}
@@ -4,11 +4,12 @@ export class InMemorySessionViewTokenStore {
4
4
  constructor() {
5
5
  this.tokens = new Map();
6
6
  }
7
- create(platform, platformUserId, conversationId, sessionKey, sessionFile) {
7
+ create(platform, platformUserId, conversationId, sessionKey, sessionFile, platformUserName) {
8
8
  const token = {
9
9
  token: randomBytes(16).toString("hex"),
10
10
  platform,
11
11
  platformUserId,
12
+ ...(platformUserName ? { platformUserName } : {}),
12
13
  conversationId,
13
14
  sessionKey,
14
15
  sessionFile,
@@ -1 +1 @@
1
- {"version":3,"file":"store.js","sourceRoot":"","sources":["../../src/session-view/store.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,QAAQ,CAAC;AAarC,MAAM,MAAM,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,IAAI,CAAC;AAEnC,MAAM,OAAO,6BAA6B;IAA1C;QACU,WAAM,GAAG,IAAI,GAAG,EAA4B,CAAC;IAuCvD,CAAC;IArCC,MAAM,CACJ,QAAsB,EACtB,cAAsB,EACtB,cAAsB,EACtB,UAAkB,EAClB,WAAmB;QAEnB,MAAM,KAAK,GAAqB;YAC9B,KAAK,EAAE,WAAW,CAAC,EAAE,CAAC,CAAC,QAAQ,CAAC,KAAK,CAAC;YACtC,QAAQ;YACR,cAAc;YACd,cAAc;YACd,UAAU;YACV,WAAW;YACX,SAAS,EAAE,IAAI,CAAC,GAAG,EAAE,GAAG,MAAM;SAC/B,CAAC;QACF,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,KAAK,CAAC,KAAK,EAAE,KAAK,CAAC,CAAC;QACpC,OAAO,KAAK,CAAC;IACf,CAAC;IAED,IAAI,CAAC,QAAgB;QACnB,MAAM,KAAK,GAAG,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;QACxC,IAAI,CAAC,KAAK,IAAI,IAAI,CAAC,GAAG,EAAE,GAAG,KAAK,CAAC,SAAS,EAAE,CAAC;YAC3C,IAAI,KAAK;gBAAE,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC;YACxC,OAAO,SAAS,CAAC;QACnB,CAAC;QACD,OAAO,KAAK,CAAC;IACf,CAAC;IAED,KAAK;QACH,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;QACvB,KAAK,MAAM,CAAC,GAAG,EAAE,KAAK,CAAC,IAAI,IAAI,CAAC,MAAM,EAAE,CAAC;YACvC,IAAI,GAAG,GAAG,KAAK,CAAC,SAAS,EAAE,CAAC;gBAC1B,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;YAC1B,CAAC;QACH,CAAC;IACH,CAAC;CACF","sourcesContent":["import { randomBytes } from \"crypto\";\nimport type { PlatformName } from \"../adapter.js\";\n\nexport interface SessionViewToken {\n token: string;\n platform: PlatformName;\n platformUserId: string;\n conversationId: string;\n sessionKey: string;\n sessionFile: string;\n expiresAt: number;\n}\n\nconst TTL_MS = 24 * 60 * 60 * 1000;\n\nexport class InMemorySessionViewTokenStore {\n private tokens = new Map<string, SessionViewToken>();\n\n create(\n platform: PlatformName,\n platformUserId: string,\n conversationId: string,\n sessionKey: string,\n sessionFile: string,\n ): SessionViewToken {\n const token: SessionViewToken = {\n token: randomBytes(16).toString(\"hex\"),\n platform,\n platformUserId,\n conversationId,\n sessionKey,\n sessionFile,\n expiresAt: Date.now() + TTL_MS,\n };\n this.tokens.set(token.token, token);\n return token;\n }\n\n peek(rawToken: string): SessionViewToken | undefined {\n const entry = this.tokens.get(rawToken);\n if (!entry || Date.now() > entry.expiresAt) {\n if (entry) this.tokens.delete(rawToken);\n return undefined;\n }\n return entry;\n }\n\n purge(): void {\n const now = Date.now();\n for (const [key, value] of this.tokens) {\n if (now > value.expiresAt) {\n this.tokens.delete(key);\n }\n }\n }\n}\n"]}
1
+ {"version":3,"file":"store.js","sourceRoot":"","sources":["../../src/session-view/store.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,QAAQ,CAAC;AAcrC,MAAM,MAAM,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,IAAI,CAAC;AAEnC,MAAM,OAAO,6BAA6B;IAA1C;QACU,WAAM,GAAG,IAAI,GAAG,EAA4B,CAAC;IAyCvD,CAAC;IAvCC,MAAM,CACJ,QAAsB,EACtB,cAAsB,EACtB,cAAsB,EACtB,UAAkB,EAClB,WAAmB,EACnB,gBAAyB;QAEzB,MAAM,KAAK,GAAqB;YAC9B,KAAK,EAAE,WAAW,CAAC,EAAE,CAAC,CAAC,QAAQ,CAAC,KAAK,CAAC;YACtC,QAAQ;YACR,cAAc;YACd,GAAG,CAAC,gBAAgB,CAAC,CAAC,CAAC,EAAE,gBAAgB,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;YACjD,cAAc;YACd,UAAU;YACV,WAAW;YACX,SAAS,EAAE,IAAI,CAAC,GAAG,EAAE,GAAG,MAAM;SAC/B,CAAC;QACF,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,KAAK,CAAC,KAAK,EAAE,KAAK,CAAC,CAAC;QACpC,OAAO,KAAK,CAAC;IACf,CAAC;IAED,IAAI,CAAC,QAAgB;QACnB,MAAM,KAAK,GAAG,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;QACxC,IAAI,CAAC,KAAK,IAAI,IAAI,CAAC,GAAG,EAAE,GAAG,KAAK,CAAC,SAAS,EAAE,CAAC;YAC3C,IAAI,KAAK;gBAAE,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC;YACxC,OAAO,SAAS,CAAC;QACnB,CAAC;QACD,OAAO,KAAK,CAAC;IACf,CAAC;IAED,KAAK;QACH,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;QACvB,KAAK,MAAM,CAAC,GAAG,EAAE,KAAK,CAAC,IAAI,IAAI,CAAC,MAAM,EAAE,CAAC;YACvC,IAAI,GAAG,GAAG,KAAK,CAAC,SAAS,EAAE,CAAC;gBAC1B,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;YAC1B,CAAC;QACH,CAAC;IACH,CAAC;CACF","sourcesContent":["import { randomBytes } from \"crypto\";\nimport type { PlatformName } from \"../adapter.js\";\n\nexport interface SessionViewToken {\n token: string;\n platform: PlatformName;\n platformUserId: string;\n platformUserName?: string;\n conversationId: string;\n sessionKey: string;\n sessionFile: string;\n expiresAt: number;\n}\n\nconst TTL_MS = 24 * 60 * 60 * 1000;\n\nexport class InMemorySessionViewTokenStore {\n private tokens = new Map<string, SessionViewToken>();\n\n create(\n platform: PlatformName,\n platformUserId: string,\n conversationId: string,\n sessionKey: string,\n sessionFile: string,\n platformUserName?: string,\n ): SessionViewToken {\n const token: SessionViewToken = {\n token: randomBytes(16).toString(\"hex\"),\n platform,\n platformUserId,\n ...(platformUserName ? { platformUserName } : {}),\n conversationId,\n sessionKey,\n sessionFile,\n expiresAt: Date.now() + TTL_MS,\n };\n this.tokens.set(token.token, token);\n return token;\n }\n\n peek(rawToken: string): SessionViewToken | undefined {\n const entry = this.tokens.get(rawToken);\n if (!entry || Date.now() > entry.expiresAt) {\n if (entry) this.tokens.delete(rawToken);\n return undefined;\n }\n return entry;\n }\n\n purge(): void {\n const now = Date.now();\n for (const [key, value] of this.tokens) {\n if (now > value.expiresAt) {\n this.tokens.delete(key);\n }\n }\n }\n}\n"]}
@@ -1 +1 @@
1
- {"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../src/store.ts"],"names":[],"mappings":"AAKA,MAAM,WAAW,UAAU;IACzB,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,UAAU,EAAE,CAAC;IAC1B,KAAK,EAAE,OAAO,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,kBAAkB;IACjC,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,qBAAa,YAAY;IACvB,OAAO,CAAC,UAAU,CAAS;IAC3B,OAAO,CAAC,QAAQ,CAAS;IAGzB,OAAO,CAAC,cAAc,CAA6B;IAEnD,YAAY,MAAM,EAAE,kBAAkB,EAQrC;IAED;;OAEG;IACH,aAAa,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAMvC;IAED;;OAEG;IACH,qBAAqB,CAAC,YAAY,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,CAMrE;IAED;;;OAGG;IACG,kBAAkB,CACtB,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,KAAK,CAAC;QAAE,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,oBAAoB,CAAC,EAAE,MAAM,CAAC;QAAC,WAAW,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,EACpF,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,UAAU,EAAE,CAAC,CA+BvB;IAED;;;OAGG;IACG,UAAU,CAAC,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,aAAa,GAAG,OAAO,CAAC,OAAO,CAAC,CA8B5E;IAED;;OAEG;IACG,cAAc,CAAC,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAS/E;IAED;;;OAGG;IACH,gBAAgB,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAkBjD;YAKa,kBAAkB;CAsBjC","sourcesContent":["import { existsSync, mkdirSync, readFileSync } from \"fs\";\nimport { appendFile, writeFile } from \"fs/promises\";\nimport { join } from \"path\";\nimport * as log from \"./log.js\";\n\nexport interface Attachment {\n original: string; // original filename from uploader\n localPath: string; // path relative to working dir (e.g., \"C12345/attachments/1732531234567_file.png\")\n}\n\nexport interface LoggedMessage {\n date: string; // ISO 8601 date (e.g., \"2025-11-26T10:44:00.000Z\") for easy grepping\n ts: string; // slack timestamp or epoch ms\n user: string; // user ID (or \"bot\" for bot responses)\n userName?: string; // handle (e.g., \"mario\")\n displayName?: string; // display name (e.g., \"Mario Zechner\")\n text: string;\n attachments: Attachment[];\n isBot: boolean;\n threadTs?: string; // slack thread timestamp (root message ts)\n}\n\nexport interface ChannelStoreConfig {\n workingDir: string;\n botToken: string; // needed for authenticated file downloads\n}\n\nexport class ChannelStore {\n private workingDir: string;\n private botToken: string;\n // Track recently logged message timestamps to prevent duplicates\n // Key: \"channelId:ts\", automatically cleaned up after 60 seconds\n private recentlyLogged = new Map<string, number>();\n\n constructor(config: ChannelStoreConfig) {\n this.workingDir = config.workingDir;\n this.botToken = config.botToken;\n\n // Ensure working directory exists\n if (!existsSync(this.workingDir)) {\n mkdirSync(this.workingDir, { recursive: true });\n }\n }\n\n /**\n * Get or create the directory for a channel/DM\n */\n getChannelDir(channelId: string): string {\n const channelDir = join(this.workingDir, channelId);\n if (!existsSync(channelDir)) {\n mkdirSync(channelDir, { recursive: true });\n }\n return channelDir;\n }\n\n /**\n * Generate a unique local filename for an attachment\n */\n generateLocalFilename(originalName: string, timestamp: string): string {\n // Convert slack timestamp (1234567890.123456) to milliseconds\n const ts = Math.floor(parseFloat(timestamp) * 1000);\n // Sanitize original name (remove problematic characters)\n const sanitized = originalName.replace(/[^a-zA-Z0-9._-]/g, \"_\");\n return `${ts}_${sanitized}`;\n }\n\n /**\n * Process attachments from a Slack message event.\n * Downloads files before returning so callers only receive readable paths.\n */\n async processAttachments(\n channelId: string,\n files: Array<{ name?: string; url_private_download?: string; url_private?: string }>,\n timestamp: string,\n ): Promise<Attachment[]> {\n const downloads: Array<Promise<Attachment | null>> = [];\n\n for (const file of files) {\n const url = file.url_private_download || file.url_private;\n if (!url) continue;\n if (!file.name) {\n log.logWarning(\"Attachment missing name, skipping\", url);\n continue;\n }\n\n const filename = this.generateLocalFilename(file.name, timestamp);\n const localPath = `${channelId}/attachments/${filename}`;\n const attachment: Attachment = {\n original: file.name,\n localPath,\n };\n\n downloads.push(\n this.downloadAttachment(localPath, url)\n .then(() => attachment)\n .catch((error) => {\n const errorMsg = error instanceof Error ? error.message : String(error);\n log.logWarning(`Failed to download attachment`, `${localPath}: ${errorMsg}`);\n return null;\n }),\n );\n }\n\n const attachments = await Promise.all(downloads);\n return attachments.filter((attachment): attachment is Attachment => attachment !== null);\n }\n\n /**\n * Log a message to the channel's log.jsonl\n * Returns false if message was already logged (duplicate)\n */\n async logMessage(channelId: string, message: LoggedMessage): Promise<boolean> {\n // Check for duplicate (same channel + timestamp)\n const dedupeKey = `${channelId}:${message.ts}`;\n if (this.recentlyLogged.has(dedupeKey)) {\n return false; // Already logged\n }\n\n // Mark as logged and schedule cleanup after 60 seconds\n this.recentlyLogged.set(dedupeKey, Date.now());\n setTimeout(() => this.recentlyLogged.delete(dedupeKey), 60000);\n\n const logPath = join(this.getChannelDir(channelId), \"log.jsonl\");\n\n // Ensure message has a date field\n if (!message.date) {\n // Parse timestamp to get date\n let date: Date;\n if (message.ts.includes(\".\")) {\n // Slack timestamp format (1234567890.123456)\n date = new Date(parseFloat(message.ts) * 1000);\n } else {\n // Epoch milliseconds\n date = new Date(parseInt(message.ts, 10));\n }\n message.date = date.toISOString();\n }\n\n const line = `${JSON.stringify(message)}\\n`;\n await appendFile(logPath, line, \"utf-8\");\n return true;\n }\n\n /**\n * Log a bot response\n */\n async logBotResponse(channelId: string, text: string, ts: string): Promise<void> {\n await this.logMessage(channelId, {\n date: new Date().toISOString(),\n ts,\n user: \"bot\",\n text,\n attachments: [],\n isBot: true,\n });\n }\n\n /**\n * Get the timestamp of the last logged message for a channel\n * Returns null if no log exists\n */\n getLastTimestamp(channelId: string): string | null {\n const logPath = join(this.workingDir, channelId, \"log.jsonl\");\n if (!existsSync(logPath)) {\n return null;\n }\n\n try {\n const content = readFileSync(logPath, \"utf-8\");\n const lines = content.trim().split(\"\\n\");\n if (lines.length === 0 || lines[0] === \"\") {\n return null;\n }\n const lastLine = lines[lines.length - 1];\n const message = JSON.parse(lastLine) as LoggedMessage;\n return message.ts;\n } catch {\n return null;\n }\n }\n\n /**\n * Download a single attachment\n */\n private async downloadAttachment(localPath: string, url: string): Promise<void> {\n const filePath = join(this.workingDir, localPath);\n\n // Ensure directory exists\n const parentDir = join(this.workingDir, localPath.substring(0, localPath.lastIndexOf(\"/\")));\n if (!existsSync(parentDir)) {\n mkdirSync(parentDir, { recursive: true });\n }\n\n const response = await fetch(url, {\n headers: {\n Authorization: `Bearer ${this.botToken}`,\n },\n });\n\n if (!response.ok) {\n throw new Error(`HTTP ${response.status}: ${response.statusText}`);\n }\n\n const buffer = await response.arrayBuffer();\n await writeFile(filePath, Buffer.from(buffer));\n }\n}\n"]}
1
+ {"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../src/store.ts"],"names":[],"mappings":"AAKA,MAAM,WAAW,UAAU;IACzB,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,UAAU,EAAE,CAAC;IAC1B,KAAK,EAAE,OAAO,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,kBAAkB;IACjC,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,qBAAa,YAAY;IACvB,OAAO,CAAC,UAAU,CAAS;IAC3B,OAAO,CAAC,QAAQ,CAAS;IAGzB,OAAO,CAAC,cAAc,CAA6B;IAEnD,YAAY,MAAM,EAAE,kBAAkB,EAMrC;IAED;;OAEG;IACH,aAAa,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAIvC;IAED;;OAEG;IACH,qBAAqB,CAAC,YAAY,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,CAMrE;IAED;;;OAGG;IACG,kBAAkB,CACtB,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,KAAK,CAAC;QAAE,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,oBAAoB,CAAC,EAAE,MAAM,CAAC;QAAC,WAAW,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,EACpF,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,UAAU,EAAE,CAAC,CA+BvB;IAED;;;OAGG;IACG,UAAU,CAAC,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,aAAa,GAAG,OAAO,CAAC,OAAO,CAAC,CA8B5E;IAED;;OAEG;IACG,cAAc,CAAC,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAS/E;IAED;;;OAGG;IACH,gBAAgB,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAsBjD;YAKa,kBAAkB;CAoBjC","sourcesContent":["import { appendFile, writeFile } from \"fs/promises\";\nimport { join } from \"path\";\nimport { ensureDirExists, isRecord, parseJsonValue, readTextFileIfExists } from \"./file-guards.js\";\nimport * as log from \"./log.js\";\n\nexport interface Attachment {\n original: string; // original filename from uploader\n localPath: string; // path relative to working dir (e.g., \"C12345/attachments/1732531234567_file.png\")\n}\n\nexport interface LoggedMessage {\n date: string; // ISO 8601 date (e.g., \"2025-11-26T10:44:00.000Z\") for easy grepping\n ts: string; // slack timestamp or epoch ms\n user: string; // user ID (or \"bot\" for bot responses)\n userName?: string; // handle (e.g., \"mario\")\n displayName?: string; // display name (e.g., \"Mario Zechner\")\n text: string;\n attachments: Attachment[];\n isBot: boolean;\n threadTs?: string; // slack thread timestamp (root message ts)\n}\n\nexport interface ChannelStoreConfig {\n workingDir: string;\n botToken: string; // needed for authenticated file downloads\n}\n\nexport class ChannelStore {\n private workingDir: string;\n private botToken: string;\n // Track recently logged message timestamps to prevent duplicates\n // Key: \"channelId:ts\", automatically cleaned up after 60 seconds\n private recentlyLogged = new Map<string, number>();\n\n constructor(config: ChannelStoreConfig) {\n this.workingDir = config.workingDir;\n this.botToken = config.botToken;\n\n // Ensure working directory exists\n ensureDirExists(this.workingDir);\n }\n\n /**\n * Get or create the directory for a channel/DM\n */\n getChannelDir(channelId: string): string {\n const channelDir = join(this.workingDir, channelId);\n ensureDirExists(channelDir);\n return channelDir;\n }\n\n /**\n * Generate a unique local filename for an attachment\n */\n generateLocalFilename(originalName: string, timestamp: string): string {\n // Convert slack timestamp (1234567890.123456) to milliseconds\n const ts = Math.floor(parseFloat(timestamp) * 1000);\n // Sanitize original name (remove problematic characters)\n const sanitized = originalName.replace(/[^a-zA-Z0-9._-]/g, \"_\");\n return `${ts}_${sanitized}`;\n }\n\n /**\n * Process attachments from a Slack message event.\n * Downloads files before returning so callers only receive readable paths.\n */\n async processAttachments(\n channelId: string,\n files: Array<{ name?: string; url_private_download?: string; url_private?: string }>,\n timestamp: string,\n ): Promise<Attachment[]> {\n const downloads: Array<Promise<Attachment | null>> = [];\n\n for (const file of files) {\n const url = file.url_private_download || file.url_private;\n if (!url) continue;\n if (!file.name) {\n log.logWarning(\"Attachment missing name, skipping\", url);\n continue;\n }\n\n const filename = this.generateLocalFilename(file.name, timestamp);\n const localPath = `${channelId}/attachments/${filename}`;\n const attachment: Attachment = {\n original: file.name,\n localPath,\n };\n\n downloads.push(\n this.downloadAttachment(localPath, url)\n .then(() => attachment)\n .catch((error) => {\n const errorMsg = error instanceof Error ? error.message : String(error);\n log.logWarning(`Failed to download attachment`, `${localPath}: ${errorMsg}`);\n return null;\n }),\n );\n }\n\n const attachments = await Promise.all(downloads);\n return attachments.filter((attachment): attachment is Attachment => attachment !== null);\n }\n\n /**\n * Log a message to the channel's log.jsonl\n * Returns false if message was already logged (duplicate)\n */\n async logMessage(channelId: string, message: LoggedMessage): Promise<boolean> {\n // Check for duplicate (same channel + timestamp)\n const dedupeKey = `${channelId}:${message.ts}`;\n if (this.recentlyLogged.has(dedupeKey)) {\n return false; // Already logged\n }\n\n // Mark as logged and schedule cleanup after 60 seconds\n this.recentlyLogged.set(dedupeKey, Date.now());\n setTimeout(() => this.recentlyLogged.delete(dedupeKey), 60000);\n\n const logPath = join(this.getChannelDir(channelId), \"log.jsonl\");\n\n // Ensure message has a date field\n if (!message.date) {\n // Parse timestamp to get date\n let date: Date;\n if (message.ts.includes(\".\")) {\n // Slack timestamp format (1234567890.123456)\n date = new Date(parseFloat(message.ts) * 1000);\n } else {\n // Epoch milliseconds\n date = new Date(parseInt(message.ts, 10));\n }\n message.date = date.toISOString();\n }\n\n const line = `${JSON.stringify(message)}\\n`;\n await appendFile(logPath, line, \"utf-8\");\n return true;\n }\n\n /**\n * Log a bot response\n */\n async logBotResponse(channelId: string, text: string, ts: string): Promise<void> {\n await this.logMessage(channelId, {\n date: new Date().toISOString(),\n ts,\n user: \"bot\",\n text,\n attachments: [],\n isBot: true,\n });\n }\n\n /**\n * Get the timestamp of the last logged message for a channel\n * Returns null if no log exists\n */\n getLastTimestamp(channelId: string): string | null {\n const logPath = join(this.workingDir, channelId, \"log.jsonl\");\n const content = readTextFileIfExists(logPath);\n if (content === undefined) {\n return null;\n }\n\n try {\n const lines = content.trim().split(\"\\n\");\n if (lines.length === 0 || lines[0] === \"\") {\n return null;\n }\n const lastLine = lines[lines.length - 1];\n const message = parseJsonValue(\n lastLine,\n (value): value is LoggedMessage => isRecord(value) && typeof value.ts === \"string\",\n (detail) => (detail === \"unexpected JSON shape\" ? \"log entry missing timestamp\" : detail),\n );\n return message.ts;\n } catch {\n return null;\n }\n }\n\n /**\n * Download a single attachment\n */\n private async downloadAttachment(localPath: string, url: string): Promise<void> {\n const filePath = join(this.workingDir, localPath);\n\n // Ensure directory exists\n const parentDir = join(this.workingDir, localPath.substring(0, localPath.lastIndexOf(\"/\")));\n ensureDirExists(parentDir);\n\n const response = await fetch(url, {\n headers: {\n Authorization: `Bearer ${this.botToken}`,\n },\n });\n\n if (!response.ok) {\n throw new Error(`HTTP ${response.status}: ${response.statusText}`);\n }\n\n const buffer = await response.arrayBuffer();\n await writeFile(filePath, Buffer.from(buffer));\n }\n}\n"]}
package/dist/store.js CHANGED
@@ -1,6 +1,6 @@
1
- import { existsSync, mkdirSync, readFileSync } from "fs";
2
1
  import { appendFile, writeFile } from "fs/promises";
3
2
  import { join } from "path";
3
+ import { ensureDirExists, isRecord, parseJsonValue, readTextFileIfExists } from "./file-guards.js";
4
4
  import * as log from "./log.js";
5
5
  export class ChannelStore {
6
6
  constructor(config) {
@@ -10,18 +10,14 @@ export class ChannelStore {
10
10
  this.workingDir = config.workingDir;
11
11
  this.botToken = config.botToken;
12
12
  // Ensure working directory exists
13
- if (!existsSync(this.workingDir)) {
14
- mkdirSync(this.workingDir, { recursive: true });
15
- }
13
+ ensureDirExists(this.workingDir);
16
14
  }
17
15
  /**
18
16
  * Get or create the directory for a channel/DM
19
17
  */
20
18
  getChannelDir(channelId) {
21
19
  const channelDir = join(this.workingDir, channelId);
22
- if (!existsSync(channelDir)) {
23
- mkdirSync(channelDir, { recursive: true });
24
- }
20
+ ensureDirExists(channelDir);
25
21
  return channelDir;
26
22
  }
27
23
  /**
@@ -116,17 +112,17 @@ export class ChannelStore {
116
112
  */
117
113
  getLastTimestamp(channelId) {
118
114
  const logPath = join(this.workingDir, channelId, "log.jsonl");
119
- if (!existsSync(logPath)) {
115
+ const content = readTextFileIfExists(logPath);
116
+ if (content === undefined) {
120
117
  return null;
121
118
  }
122
119
  try {
123
- const content = readFileSync(logPath, "utf-8");
124
120
  const lines = content.trim().split("\n");
125
121
  if (lines.length === 0 || lines[0] === "") {
126
122
  return null;
127
123
  }
128
124
  const lastLine = lines[lines.length - 1];
129
- const message = JSON.parse(lastLine);
125
+ const message = parseJsonValue(lastLine, (value) => isRecord(value) && typeof value.ts === "string", (detail) => (detail === "unexpected JSON shape" ? "log entry missing timestamp" : detail));
130
126
  return message.ts;
131
127
  }
132
128
  catch {
@@ -140,9 +136,7 @@ export class ChannelStore {
140
136
  const filePath = join(this.workingDir, localPath);
141
137
  // Ensure directory exists
142
138
  const parentDir = join(this.workingDir, localPath.substring(0, localPath.lastIndexOf("/")));
143
- if (!existsSync(parentDir)) {
144
- mkdirSync(parentDir, { recursive: true });
145
- }
139
+ ensureDirExists(parentDir);
146
140
  const response = await fetch(url, {
147
141
  headers: {
148
142
  Authorization: `Bearer ${this.botToken}`,
package/dist/store.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"store.js","sourceRoot":"","sources":["../src/store.ts"],"names":[],"mappings":"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","sourcesContent":["import { existsSync, mkdirSync, readFileSync } from \"fs\";\nimport { appendFile, writeFile } from \"fs/promises\";\nimport { join } from \"path\";\nimport * as log from \"./log.js\";\n\nexport interface Attachment {\n original: string; // original filename from uploader\n localPath: string; // path relative to working dir (e.g., \"C12345/attachments/1732531234567_file.png\")\n}\n\nexport interface LoggedMessage {\n date: string; // ISO 8601 date (e.g., \"2025-11-26T10:44:00.000Z\") for easy grepping\n ts: string; // slack timestamp or epoch ms\n user: string; // user ID (or \"bot\" for bot responses)\n userName?: string; // handle (e.g., \"mario\")\n displayName?: string; // display name (e.g., \"Mario Zechner\")\n text: string;\n attachments: Attachment[];\n isBot: boolean;\n threadTs?: string; // slack thread timestamp (root message ts)\n}\n\nexport interface ChannelStoreConfig {\n workingDir: string;\n botToken: string; // needed for authenticated file downloads\n}\n\nexport class ChannelStore {\n private workingDir: string;\n private botToken: string;\n // Track recently logged message timestamps to prevent duplicates\n // Key: \"channelId:ts\", automatically cleaned up after 60 seconds\n private recentlyLogged = new Map<string, number>();\n\n constructor(config: ChannelStoreConfig) {\n this.workingDir = config.workingDir;\n this.botToken = config.botToken;\n\n // Ensure working directory exists\n if (!existsSync(this.workingDir)) {\n mkdirSync(this.workingDir, { recursive: true });\n }\n }\n\n /**\n * Get or create the directory for a channel/DM\n */\n getChannelDir(channelId: string): string {\n const channelDir = join(this.workingDir, channelId);\n if (!existsSync(channelDir)) {\n mkdirSync(channelDir, { recursive: true });\n }\n return channelDir;\n }\n\n /**\n * Generate a unique local filename for an attachment\n */\n generateLocalFilename(originalName: string, timestamp: string): string {\n // Convert slack timestamp (1234567890.123456) to milliseconds\n const ts = Math.floor(parseFloat(timestamp) * 1000);\n // Sanitize original name (remove problematic characters)\n const sanitized = originalName.replace(/[^a-zA-Z0-9._-]/g, \"_\");\n return `${ts}_${sanitized}`;\n }\n\n /**\n * Process attachments from a Slack message event.\n * Downloads files before returning so callers only receive readable paths.\n */\n async processAttachments(\n channelId: string,\n files: Array<{ name?: string; url_private_download?: string; url_private?: string }>,\n timestamp: string,\n ): Promise<Attachment[]> {\n const downloads: Array<Promise<Attachment | null>> = [];\n\n for (const file of files) {\n const url = file.url_private_download || file.url_private;\n if (!url) continue;\n if (!file.name) {\n log.logWarning(\"Attachment missing name, skipping\", url);\n continue;\n }\n\n const filename = this.generateLocalFilename(file.name, timestamp);\n const localPath = `${channelId}/attachments/${filename}`;\n const attachment: Attachment = {\n original: file.name,\n localPath,\n };\n\n downloads.push(\n this.downloadAttachment(localPath, url)\n .then(() => attachment)\n .catch((error) => {\n const errorMsg = error instanceof Error ? error.message : String(error);\n log.logWarning(`Failed to download attachment`, `${localPath}: ${errorMsg}`);\n return null;\n }),\n );\n }\n\n const attachments = await Promise.all(downloads);\n return attachments.filter((attachment): attachment is Attachment => attachment !== null);\n }\n\n /**\n * Log a message to the channel's log.jsonl\n * Returns false if message was already logged (duplicate)\n */\n async logMessage(channelId: string, message: LoggedMessage): Promise<boolean> {\n // Check for duplicate (same channel + timestamp)\n const dedupeKey = `${channelId}:${message.ts}`;\n if (this.recentlyLogged.has(dedupeKey)) {\n return false; // Already logged\n }\n\n // Mark as logged and schedule cleanup after 60 seconds\n this.recentlyLogged.set(dedupeKey, Date.now());\n setTimeout(() => this.recentlyLogged.delete(dedupeKey), 60000);\n\n const logPath = join(this.getChannelDir(channelId), \"log.jsonl\");\n\n // Ensure message has a date field\n if (!message.date) {\n // Parse timestamp to get date\n let date: Date;\n if (message.ts.includes(\".\")) {\n // Slack timestamp format (1234567890.123456)\n date = new Date(parseFloat(message.ts) * 1000);\n } else {\n // Epoch milliseconds\n date = new Date(parseInt(message.ts, 10));\n }\n message.date = date.toISOString();\n }\n\n const line = `${JSON.stringify(message)}\\n`;\n await appendFile(logPath, line, \"utf-8\");\n return true;\n }\n\n /**\n * Log a bot response\n */\n async logBotResponse(channelId: string, text: string, ts: string): Promise<void> {\n await this.logMessage(channelId, {\n date: new Date().toISOString(),\n ts,\n user: \"bot\",\n text,\n attachments: [],\n isBot: true,\n });\n }\n\n /**\n * Get the timestamp of the last logged message for a channel\n * Returns null if no log exists\n */\n getLastTimestamp(channelId: string): string | null {\n const logPath = join(this.workingDir, channelId, \"log.jsonl\");\n if (!existsSync(logPath)) {\n return null;\n }\n\n try {\n const content = readFileSync(logPath, \"utf-8\");\n const lines = content.trim().split(\"\\n\");\n if (lines.length === 0 || lines[0] === \"\") {\n return null;\n }\n const lastLine = lines[lines.length - 1];\n const message = JSON.parse(lastLine) as LoggedMessage;\n return message.ts;\n } catch {\n return null;\n }\n }\n\n /**\n * Download a single attachment\n */\n private async downloadAttachment(localPath: string, url: string): Promise<void> {\n const filePath = join(this.workingDir, localPath);\n\n // Ensure directory exists\n const parentDir = join(this.workingDir, localPath.substring(0, localPath.lastIndexOf(\"/\")));\n if (!existsSync(parentDir)) {\n mkdirSync(parentDir, { recursive: true });\n }\n\n const response = await fetch(url, {\n headers: {\n Authorization: `Bearer ${this.botToken}`,\n },\n });\n\n if (!response.ok) {\n throw new Error(`HTTP ${response.status}: ${response.statusText}`);\n }\n\n const buffer = await response.arrayBuffer();\n await writeFile(filePath, Buffer.from(buffer));\n }\n}\n"]}
1
+ {"version":3,"file":"store.js","sourceRoot":"","sources":["../src/store.ts"],"names":[],"mappings":"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","sourcesContent":["import { appendFile, writeFile } from \"fs/promises\";\nimport { join } from \"path\";\nimport { ensureDirExists, isRecord, parseJsonValue, readTextFileIfExists } from \"./file-guards.js\";\nimport * as log from \"./log.js\";\n\nexport interface Attachment {\n original: string; // original filename from uploader\n localPath: string; // path relative to working dir (e.g., \"C12345/attachments/1732531234567_file.png\")\n}\n\nexport interface LoggedMessage {\n date: string; // ISO 8601 date (e.g., \"2025-11-26T10:44:00.000Z\") for easy grepping\n ts: string; // slack timestamp or epoch ms\n user: string; // user ID (or \"bot\" for bot responses)\n userName?: string; // handle (e.g., \"mario\")\n displayName?: string; // display name (e.g., \"Mario Zechner\")\n text: string;\n attachments: Attachment[];\n isBot: boolean;\n threadTs?: string; // slack thread timestamp (root message ts)\n}\n\nexport interface ChannelStoreConfig {\n workingDir: string;\n botToken: string; // needed for authenticated file downloads\n}\n\nexport class ChannelStore {\n private workingDir: string;\n private botToken: string;\n // Track recently logged message timestamps to prevent duplicates\n // Key: \"channelId:ts\", automatically cleaned up after 60 seconds\n private recentlyLogged = new Map<string, number>();\n\n constructor(config: ChannelStoreConfig) {\n this.workingDir = config.workingDir;\n this.botToken = config.botToken;\n\n // Ensure working directory exists\n ensureDirExists(this.workingDir);\n }\n\n /**\n * Get or create the directory for a channel/DM\n */\n getChannelDir(channelId: string): string {\n const channelDir = join(this.workingDir, channelId);\n ensureDirExists(channelDir);\n return channelDir;\n }\n\n /**\n * Generate a unique local filename for an attachment\n */\n generateLocalFilename(originalName: string, timestamp: string): string {\n // Convert slack timestamp (1234567890.123456) to milliseconds\n const ts = Math.floor(parseFloat(timestamp) * 1000);\n // Sanitize original name (remove problematic characters)\n const sanitized = originalName.replace(/[^a-zA-Z0-9._-]/g, \"_\");\n return `${ts}_${sanitized}`;\n }\n\n /**\n * Process attachments from a Slack message event.\n * Downloads files before returning so callers only receive readable paths.\n */\n async processAttachments(\n channelId: string,\n files: Array<{ name?: string; url_private_download?: string; url_private?: string }>,\n timestamp: string,\n ): Promise<Attachment[]> {\n const downloads: Array<Promise<Attachment | null>> = [];\n\n for (const file of files) {\n const url = file.url_private_download || file.url_private;\n if (!url) continue;\n if (!file.name) {\n log.logWarning(\"Attachment missing name, skipping\", url);\n continue;\n }\n\n const filename = this.generateLocalFilename(file.name, timestamp);\n const localPath = `${channelId}/attachments/${filename}`;\n const attachment: Attachment = {\n original: file.name,\n localPath,\n };\n\n downloads.push(\n this.downloadAttachment(localPath, url)\n .then(() => attachment)\n .catch((error) => {\n const errorMsg = error instanceof Error ? error.message : String(error);\n log.logWarning(`Failed to download attachment`, `${localPath}: ${errorMsg}`);\n return null;\n }),\n );\n }\n\n const attachments = await Promise.all(downloads);\n return attachments.filter((attachment): attachment is Attachment => attachment !== null);\n }\n\n /**\n * Log a message to the channel's log.jsonl\n * Returns false if message was already logged (duplicate)\n */\n async logMessage(channelId: string, message: LoggedMessage): Promise<boolean> {\n // Check for duplicate (same channel + timestamp)\n const dedupeKey = `${channelId}:${message.ts}`;\n if (this.recentlyLogged.has(dedupeKey)) {\n return false; // Already logged\n }\n\n // Mark as logged and schedule cleanup after 60 seconds\n this.recentlyLogged.set(dedupeKey, Date.now());\n setTimeout(() => this.recentlyLogged.delete(dedupeKey), 60000);\n\n const logPath = join(this.getChannelDir(channelId), \"log.jsonl\");\n\n // Ensure message has a date field\n if (!message.date) {\n // Parse timestamp to get date\n let date: Date;\n if (message.ts.includes(\".\")) {\n // Slack timestamp format (1234567890.123456)\n date = new Date(parseFloat(message.ts) * 1000);\n } else {\n // Epoch milliseconds\n date = new Date(parseInt(message.ts, 10));\n }\n message.date = date.toISOString();\n }\n\n const line = `${JSON.stringify(message)}\\n`;\n await appendFile(logPath, line, \"utf-8\");\n return true;\n }\n\n /**\n * Log a bot response\n */\n async logBotResponse(channelId: string, text: string, ts: string): Promise<void> {\n await this.logMessage(channelId, {\n date: new Date().toISOString(),\n ts,\n user: \"bot\",\n text,\n attachments: [],\n isBot: true,\n });\n }\n\n /**\n * Get the timestamp of the last logged message for a channel\n * Returns null if no log exists\n */\n getLastTimestamp(channelId: string): string | null {\n const logPath = join(this.workingDir, channelId, \"log.jsonl\");\n const content = readTextFileIfExists(logPath);\n if (content === undefined) {\n return null;\n }\n\n try {\n const lines = content.trim().split(\"\\n\");\n if (lines.length === 0 || lines[0] === \"\") {\n return null;\n }\n const lastLine = lines[lines.length - 1];\n const message = parseJsonValue(\n lastLine,\n (value): value is LoggedMessage => isRecord(value) && typeof value.ts === \"string\",\n (detail) => (detail === \"unexpected JSON shape\" ? \"log entry missing timestamp\" : detail),\n );\n return message.ts;\n } catch {\n return null;\n }\n }\n\n /**\n * Download a single attachment\n */\n private async downloadAttachment(localPath: string, url: string): Promise<void> {\n const filePath = join(this.workingDir, localPath);\n\n // Ensure directory exists\n const parentDir = join(this.workingDir, localPath.substring(0, localPath.lastIndexOf(\"/\")));\n ensureDirExists(parentDir);\n\n const response = await fetch(url, {\n headers: {\n Authorization: `Bearer ${this.botToken}`,\n },\n });\n\n if (!response.ok) {\n throw new Error(`HTTP ${response.status}: ${response.statusText}`);\n }\n\n const buffer = await response.arrayBuffer();\n await writeFile(filePath, Buffer.from(buffer));\n }\n}\n"]}
@@ -0,0 +1,2 @@
1
+ export declare function shouldSurfaceToolDiagnostic(toolName: string): boolean;
2
+ //# sourceMappingURL=tool-diagnostics.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tool-diagnostics.d.ts","sourceRoot":"","sources":["../src/tool-diagnostics.ts"],"names":[],"mappings":"AAIA,wBAAgB,2BAA2B,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAErE","sourcesContent":["// Central policy for what tool diagnostics are posted back to chat surfaces.\n// Detailed tool calls/results still remain in the structured session history and session view.\nconst QUIET_TOOL_DIAGNOSTICS = new Set([\"bash\", \"read\", \"write\", \"edit\"]);\n\nexport function shouldSurfaceToolDiagnostic(toolName: string): boolean {\n return !QUIET_TOOL_DIAGNOSTICS.has(toolName);\n}\n"]}
@@ -0,0 +1,7 @@
1
+ // Central policy for what tool diagnostics are posted back to chat surfaces.
2
+ // Detailed tool calls/results still remain in the structured session history and session view.
3
+ const QUIET_TOOL_DIAGNOSTICS = new Set(["bash", "read", "write", "edit"]);
4
+ export function shouldSurfaceToolDiagnostic(toolName) {
5
+ return !QUIET_TOOL_DIAGNOSTICS.has(toolName);
6
+ }
7
+ //# sourceMappingURL=tool-diagnostics.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tool-diagnostics.js","sourceRoot":"","sources":["../src/tool-diagnostics.ts"],"names":[],"mappings":"AAAA,6EAA6E;AAC7E,+FAA+F;AAC/F,MAAM,sBAAsB,GAAG,IAAI,GAAG,CAAC,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,CAAC,CAAC,CAAC;AAE1E,MAAM,UAAU,2BAA2B,CAAC,QAAgB;IAC1D,OAAO,CAAC,sBAAsB,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;AAC/C,CAAC","sourcesContent":["// Central policy for what tool diagnostics are posted back to chat surfaces.\n// Detailed tool calls/results still remain in the structured session history and session view.\nconst QUIET_TOOL_DIAGNOSTICS = new Set([\"bash\", \"read\", \"write\", \"edit\"]);\n\nexport function shouldSurfaceToolDiagnostic(toolName: string): boolean {\n return !QUIET_TOOL_DIAGNOSTICS.has(toolName);\n}\n"]}
@@ -1,7 +1,4 @@
1
1
  import type { SandboxConfig } from "./sandbox.js";
2
- import type { VaultEntry } from "./vault.js";
3
2
  export declare function resolveActorVaultKey(baseConfig: SandboxConfig, userId: string, conversationId: string): string;
4
- export declare function createManagedVaultEntry(platform: string, conversationId: string, withImageSandbox?: boolean): VaultEntry;
5
3
  export declare function containerSharedVaultId(containerName: string): string;
6
- export declare function createSharedContainerVaultEntry(containerName: string): VaultEntry;
7
4
  //# sourceMappingURL=vault-routing.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"vault-routing.d.ts","sourceRoot":"","sources":["../src/vault-routing.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAClD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAE7C,wBAAgB,oBAAoB,CAClC,UAAU,EAAE,aAAa,EACzB,MAAM,EAAE,MAAM,EACd,cAAc,EAAE,MAAM,GACrB,MAAM,CAcR;AAED,wBAAgB,uBAAuB,CACrC,QAAQ,EAAE,MAAM,EAChB,cAAc,EAAE,MAAM,EACtB,gBAAgB,UAAQ,GACvB,UAAU,CAYZ;AAED,wBAAgB,sBAAsB,CAAC,aAAa,EAAE,MAAM,GAAG,MAAM,CAEpE;AAED,wBAAgB,+BAA+B,CAAC,aAAa,EAAE,MAAM,GAAG,UAAU,CAIjF","sourcesContent":["import { DockerContainerManager } from \"./provisioner.js\";\nimport type { SandboxConfig } from \"./sandbox.js\";\nimport type { VaultEntry } from \"./vault.js\";\n\nexport function resolveActorVaultKey(\n baseConfig: SandboxConfig,\n userId: string,\n conversationId: string,\n): string {\n if (baseConfig.type === \"container\") {\n return containerSharedVaultId(baseConfig.container);\n }\n\n if (\n baseConfig.type === \"image\" ||\n baseConfig.type === \"cloudflare\" ||\n baseConfig.type === \"firecracker\"\n ) {\n return DockerContainerManager.sanitizeSegment(conversationId);\n }\n\n return userId;\n}\n\nexport function createManagedVaultEntry(\n platform: string,\n conversationId: string,\n withImageSandbox = false,\n): VaultEntry {\n return {\n displayName: `${platform}:${conversationId}`,\n platform: asVaultPlatform(platform),\n ...(withImageSandbox\n ? {\n sandbox: {\n type: \"image\" as const,\n },\n }\n : {}),\n };\n}\n\nexport function containerSharedVaultId(containerName: string): string {\n return `container-${containerName}`;\n}\n\nexport function createSharedContainerVaultEntry(containerName: string): VaultEntry {\n return {\n displayName: `container:${containerName}`,\n };\n}\n\nfunction asVaultPlatform(platform: string): VaultEntry[\"platform\"] | undefined {\n if (platform === \"slack\" || platform === \"discord\" || platform === \"telegram\") {\n return platform;\n }\n return undefined;\n}\n"]}
1
+ {"version":3,"file":"vault-routing.d.ts","sourceRoot":"","sources":["../src/vault-routing.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAClD,wBAAgB,oBAAoB,CAClC,UAAU,EAAE,aAAa,EACzB,MAAM,EAAE,MAAM,EACd,cAAc,EAAE,MAAM,GACrB,MAAM,CAcR;AAED,wBAAgB,sBAAsB,CAAC,aAAa,EAAE,MAAM,GAAG,MAAM,CAEpE","sourcesContent":["import { DockerContainerManager } from \"./provisioner.js\";\nimport type { SandboxConfig } from \"./sandbox.js\";\nexport function resolveActorVaultKey(\n baseConfig: SandboxConfig,\n userId: string,\n conversationId: string,\n): string {\n if (baseConfig.type === \"container\") {\n return containerSharedVaultId(baseConfig.container);\n }\n\n if (\n baseConfig.type === \"image\" ||\n baseConfig.type === \"cloudflare\" ||\n baseConfig.type === \"firecracker\"\n ) {\n return DockerContainerManager.sanitizeSegment(conversationId);\n }\n\n return userId;\n}\n\nexport function containerSharedVaultId(containerName: string): string {\n return `container-${containerName}`;\n}\n"]}
@@ -10,31 +10,7 @@ export function resolveActorVaultKey(baseConfig, userId, conversationId) {
10
10
  }
11
11
  return userId;
12
12
  }
13
- export function createManagedVaultEntry(platform, conversationId, withImageSandbox = false) {
14
- return {
15
- displayName: `${platform}:${conversationId}`,
16
- platform: asVaultPlatform(platform),
17
- ...(withImageSandbox
18
- ? {
19
- sandbox: {
20
- type: "image",
21
- },
22
- }
23
- : {}),
24
- };
25
- }
26
13
  export function containerSharedVaultId(containerName) {
27
14
  return `container-${containerName}`;
28
15
  }
29
- export function createSharedContainerVaultEntry(containerName) {
30
- return {
31
- displayName: `container:${containerName}`,
32
- };
33
- }
34
- function asVaultPlatform(platform) {
35
- if (platform === "slack" || platform === "discord" || platform === "telegram") {
36
- return platform;
37
- }
38
- return undefined;
39
- }
40
16
  //# sourceMappingURL=vault-routing.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"vault-routing.js","sourceRoot":"","sources":["../src/vault-routing.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,sBAAsB,EAAE,MAAM,kBAAkB,CAAC;AAI1D,MAAM,UAAU,oBAAoB,CAClC,UAAyB,EACzB,MAAc,EACd,cAAsB;IAEtB,IAAI,UAAU,CAAC,IAAI,KAAK,WAAW,EAAE,CAAC;QACpC,OAAO,sBAAsB,CAAC,UAAU,CAAC,SAAS,CAAC,CAAC;IACtD,CAAC;IAED,IACE,UAAU,CAAC,IAAI,KAAK,OAAO;QAC3B,UAAU,CAAC,IAAI,KAAK,YAAY;QAChC,UAAU,CAAC,IAAI,KAAK,aAAa,EACjC,CAAC;QACD,OAAO,sBAAsB,CAAC,eAAe,CAAC,cAAc,CAAC,CAAC;IAChE,CAAC;IAED,OAAO,MAAM,CAAC;AAChB,CAAC;AAED,MAAM,UAAU,uBAAuB,CACrC,QAAgB,EAChB,cAAsB,EACtB,gBAAgB,GAAG,KAAK;IAExB,OAAO;QACL,WAAW,EAAE,GAAG,QAAQ,IAAI,cAAc,EAAE;QAC5C,QAAQ,EAAE,eAAe,CAAC,QAAQ,CAAC;QACnC,GAAG,CAAC,gBAAgB;YAClB,CAAC,CAAC;gBACE,OAAO,EAAE;oBACP,IAAI,EAAE,OAAgB;iBACvB;aACF;YACH,CAAC,CAAC,EAAE,CAAC;KACR,CAAC;AACJ,CAAC;AAED,MAAM,UAAU,sBAAsB,CAAC,aAAqB;IAC1D,OAAO,aAAa,aAAa,EAAE,CAAC;AACtC,CAAC;AAED,MAAM,UAAU,+BAA+B,CAAC,aAAqB;IACnE,OAAO;QACL,WAAW,EAAE,aAAa,aAAa,EAAE;KAC1C,CAAC;AACJ,CAAC;AAED,SAAS,eAAe,CAAC,QAAgB;IACvC,IAAI,QAAQ,KAAK,OAAO,IAAI,QAAQ,KAAK,SAAS,IAAI,QAAQ,KAAK,UAAU,EAAE,CAAC;QAC9E,OAAO,QAAQ,CAAC;IAClB,CAAC;IACD,OAAO,SAAS,CAAC;AACnB,CAAC","sourcesContent":["import { DockerContainerManager } from \"./provisioner.js\";\nimport type { SandboxConfig } from \"./sandbox.js\";\nimport type { VaultEntry } from \"./vault.js\";\n\nexport function resolveActorVaultKey(\n baseConfig: SandboxConfig,\n userId: string,\n conversationId: string,\n): string {\n if (baseConfig.type === \"container\") {\n return containerSharedVaultId(baseConfig.container);\n }\n\n if (\n baseConfig.type === \"image\" ||\n baseConfig.type === \"cloudflare\" ||\n baseConfig.type === \"firecracker\"\n ) {\n return DockerContainerManager.sanitizeSegment(conversationId);\n }\n\n return userId;\n}\n\nexport function createManagedVaultEntry(\n platform: string,\n conversationId: string,\n withImageSandbox = false,\n): VaultEntry {\n return {\n displayName: `${platform}:${conversationId}`,\n platform: asVaultPlatform(platform),\n ...(withImageSandbox\n ? {\n sandbox: {\n type: \"image\" as const,\n },\n }\n : {}),\n };\n}\n\nexport function containerSharedVaultId(containerName: string): string {\n return `container-${containerName}`;\n}\n\nexport function createSharedContainerVaultEntry(containerName: string): VaultEntry {\n return {\n displayName: `container:${containerName}`,\n };\n}\n\nfunction asVaultPlatform(platform: string): VaultEntry[\"platform\"] | undefined {\n if (platform === \"slack\" || platform === \"discord\" || platform === \"telegram\") {\n return platform;\n }\n return undefined;\n}\n"]}
1
+ {"version":3,"file":"vault-routing.js","sourceRoot":"","sources":["../src/vault-routing.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,sBAAsB,EAAE,MAAM,kBAAkB,CAAC;AAE1D,MAAM,UAAU,oBAAoB,CAClC,UAAyB,EACzB,MAAc,EACd,cAAsB;IAEtB,IAAI,UAAU,CAAC,IAAI,KAAK,WAAW,EAAE,CAAC;QACpC,OAAO,sBAAsB,CAAC,UAAU,CAAC,SAAS,CAAC,CAAC;IACtD,CAAC;IAED,IACE,UAAU,CAAC,IAAI,KAAK,OAAO;QAC3B,UAAU,CAAC,IAAI,KAAK,YAAY;QAChC,UAAU,CAAC,IAAI,KAAK,aAAa,EACjC,CAAC;QACD,OAAO,sBAAsB,CAAC,eAAe,CAAC,cAAc,CAAC,CAAC;IAChE,CAAC;IAED,OAAO,MAAM,CAAC;AAChB,CAAC;AAED,MAAM,UAAU,sBAAsB,CAAC,aAAqB;IAC1D,OAAO,aAAa,aAAa,EAAE,CAAC;AACtC,CAAC","sourcesContent":["import { DockerContainerManager } from \"./provisioner.js\";\nimport type { SandboxConfig } from \"./sandbox.js\";\nexport function resolveActorVaultKey(\n baseConfig: SandboxConfig,\n userId: string,\n conversationId: string,\n): string {\n if (baseConfig.type === \"container\") {\n return containerSharedVaultId(baseConfig.container);\n }\n\n if (\n baseConfig.type === \"image\" ||\n baseConfig.type === \"cloudflare\" ||\n baseConfig.type === \"firecracker\"\n ) {\n return DockerContainerManager.sanitizeSegment(conversationId);\n }\n\n return userId;\n}\n\nexport function containerSharedVaultId(containerName: string): string {\n return `container-${containerName}`;\n}\n"]}
package/dist/vault.d.ts CHANGED
@@ -1,33 +1,6 @@
1
- import type { PlatformName } from "./adapter.js";
2
1
  import type { SandboxConfig } from "./sandbox.js";
3
- /** Shape of workspace/vaults/vault.json */
4
- export interface VaultConfig {
5
- vaults: Record<string, VaultEntry>;
6
- }
7
- /** Per-user vault mount entry in vault.json */
8
- export interface VaultMountEntry {
9
- source: string;
10
- target?: string;
11
- }
12
- /** Per-user vault entry in vault.json */
13
- export interface VaultEntry {
14
- displayName: string;
15
- platform?: PlatformName;
16
- /** Subdirs/files in vault dir to mount into sandbox (e.g. [".gcloud", ".ssh", ".kube"]) */
17
- mounts?: Array<string | VaultMountEntry>;
18
- /** Whether to load env file as environment variables (default: true if env file exists) */
19
- envFile?: boolean;
20
- /** Per-user sandbox config override */
21
- sandbox?: {
22
- type?: "image" | "firecracker" | "cloudflare" | "host" | "container" | "docker";
23
- container?: string;
24
- image?: string;
25
- vmId?: string;
26
- sandboxId?: string;
27
- sshUser?: string;
28
- sshPort?: number;
29
- };
30
- }
2
+ export declare function normalizeSharedVaultName(name: string): string | undefined;
3
+ export declare function sharedVaultKey(name: string): string | undefined;
31
4
  export interface ResolvedVaultMount {
32
5
  source: string;
33
6
  target: string;
@@ -42,35 +15,31 @@ export interface ResolvedVault {
42
15
  mounts: ResolvedVaultMount[];
43
16
  /** Parsed from env file */
44
17
  env: Record<string, string>;
45
- sandboxOverride?: VaultEntry["sandbox"];
46
18
  }
47
19
  export interface VaultManager {
48
- /** Return true when a vault entry or vault directory exists for this exact key. */
20
+ /** Return true when a vault directory exists for this exact key. */
49
21
  hasEntry(key: string): boolean;
50
- /** Resolve vault for a user; returns undefined when no entry exists. */
22
+ /** Resolve vault for a user; returns undefined when no directory exists. */
51
23
  resolve(userId: string): ResolvedVault | undefined;
52
24
  /** Get sandbox config with credential injection for a user */
53
25
  getSandboxConfig(userId: string, baseConfig: SandboxConfig): SandboxConfig;
54
- /** List all configured vaults */
26
+ /** List all vaults discovered under vaults/. */
55
27
  list(): ResolvedVault[];
56
- /** Re-read vault.json without restart */
57
- reload(): void;
58
- /** Check if vault system metadata is enabled (vault.json exists) */
28
+ /** Check if the vaults directory exists. */
59
29
  isEnabled(): boolean;
60
- /**
61
- * Add a vault entry and persist to disk.
62
- * No-op if the key already exists (idempotent).
63
- */
64
- addEntry(key: string, entry: VaultEntry): void;
65
- /**
66
- * Ensure a vault entry has image sandbox metadata.
67
- * Creates the entry when missing and upgrades existing entries that lack sandbox.type.
68
- */
69
- ensureImageSandboxEntry(key: string, entry: VaultEntry): void;
70
30
  /** Merge environment variables into vaults/<key>/env and persist them to disk. */
71
31
  upsertEnv(key: string, env: Record<string, string>): void;
72
32
  /** Write a private file into vaults/<key>/ and ensure it is mounted into the sandbox. */
73
33
  upsertFile(key: string, relativePath: string, content: string, targetPath?: string): void;
34
+ /** List named shared login profiles under vaults/shared/. */
35
+ listSharedVaults(): string[];
36
+ /** Delete a shared login profile's directory. Returns true when it existed. */
37
+ deleteSharedVault(name: string): boolean;
38
+ /** Copy a shared login profile's files into another vault directory. */
39
+ copySharedVaultTo(name: string, targetKey: string): {
40
+ filesCopied: number;
41
+ envKeysCopied: number;
42
+ };
74
43
  }
75
44
  /**
76
45
  * Parse a KEY=VALUE env file. Supports:
@@ -82,27 +51,22 @@ export interface VaultManager {
82
51
  */
83
52
  export declare function parseEnvFile(content: string): Record<string, string>;
84
53
  export declare class FileVaultManager implements VaultManager {
85
- private config;
86
54
  private readonly vaultsDir;
87
- private readonly configPath;
88
55
  constructor(stateDir: string);
89
- reload(): void;
90
- /** Warn for legacy or insecure vault sandbox overrides that are no longer allowed. */
91
- private warnUnsupportedSandboxTypes;
92
56
  isEnabled(): boolean;
93
57
  hasEntry(key: string): boolean;
58
+ listSharedVaults(): string[];
59
+ deleteSharedVault(name: string): boolean;
60
+ copySharedVaultTo(name: string, targetKey: string): {
61
+ filesCopied: number;
62
+ envKeysCopied: number;
63
+ };
94
64
  resolve(userId: string): ResolvedVault | undefined;
95
65
  getSandboxConfig(userId: string, baseConfig: SandboxConfig): SandboxConfig;
96
66
  list(): ResolvedVault[];
97
- addEntry(key: string, entry: VaultEntry): void;
98
- ensureImageSandboxEntry(key: string, entry: VaultEntry): void;
99
67
  upsertEnv(key: string, env: Record<string, string>): void;
100
68
  upsertFile(key: string, relativePath: string, content: string, targetPath?: string): void;
101
- private persistConfig;
102
- private readConfigFromDisk;
103
69
  private buildResolved;
104
- private inferMountsFromDir;
105
- private resolveMountEntry;
106
70
  }
107
71
  export declare function defaultVaultTargetPath(relativePath: string): string;
108
72
  //# sourceMappingURL=vault.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"vault.d.ts","sourceRoot":"","sources":["../src/vault.ts"],"names":[],"mappings":"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","sourcesContent":["import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync } from \"fs\";\nimport { dirname, isAbsolute, join, normalize, sep } from \"path\";\nimport type { PlatformName } from \"./adapter.js\";\nimport type { SandboxConfig } from \"./sandbox.js\";\nimport { atomicWritePrivateFile } from \"./fs-atomic.js\";\n\nconst PRIVATE_DIR_MODE = 0o700;\n\nfunction sanitizeCloudflareSandboxId(value: string): string {\n return (\n value\n .toLowerCase()\n .replace(/[^a-z0-9-]+/g, \"-\")\n .replace(/^-+|-+$/g, \"\") || \"unknown\"\n );\n}\n\n// ── Types ──────────────────────────────────────────────────────────────────────\n\n/** Shape of workspace/vaults/vault.json */\nexport interface VaultConfig {\n vaults: Record<string, VaultEntry>;\n}\n\n/** Per-user vault mount entry in vault.json */\nexport interface VaultMountEntry {\n source: string;\n target?: string;\n}\n\n/** Per-user vault entry in vault.json */\nexport interface VaultEntry {\n displayName: string;\n platform?: PlatformName;\n /** Subdirs/files in vault dir to mount into sandbox (e.g. [\".gcloud\", \".ssh\", \".kube\"]) */\n mounts?: Array<string | VaultMountEntry>;\n /** Whether to load env file as environment variables (default: true if env file exists) */\n envFile?: boolean;\n /** Per-user sandbox config override */\n sandbox?: {\n type?: \"image\" | \"firecracker\" | \"cloudflare\" | \"host\" | \"container\" | \"docker\";\n container?: string;\n image?: string;\n vmId?: string;\n sandboxId?: string;\n sshUser?: string;\n sshPort?: number;\n };\n}\n\nexport interface ResolvedVaultMount {\n source: string;\n target: string;\n}\n\n/** Resolved vault ready for use at runtime */\nexport interface ResolvedVault {\n userId: string;\n displayName: string;\n /** Absolute path to vault directory */\n dir: string;\n /** Absolute mount specs */\n mounts: ResolvedVaultMount[];\n /** Parsed from env file */\n env: Record<string, string>;\n sandboxOverride?: VaultEntry[\"sandbox\"];\n}\n\nexport interface VaultManager {\n /** Return true when a vault entry or vault directory exists for this exact key. */\n hasEntry(key: string): boolean;\n /** Resolve vault for a user; returns undefined when no entry exists. */\n resolve(userId: string): ResolvedVault | undefined;\n /** Get sandbox config with credential injection for a user */\n getSandboxConfig(userId: string, baseConfig: SandboxConfig): SandboxConfig;\n /** List all configured vaults */\n list(): ResolvedVault[];\n /** Re-read vault.json without restart */\n reload(): void;\n /** Check if vault system metadata is enabled (vault.json exists) */\n isEnabled(): boolean;\n /**\n * Add a vault entry and persist to disk.\n * No-op if the key already exists (idempotent).\n */\n addEntry(key: string, entry: VaultEntry): void;\n /**\n * Ensure a vault entry has image sandbox metadata.\n * Creates the entry when missing and upgrades existing entries that lack sandbox.type.\n */\n ensureImageSandboxEntry(key: string, entry: VaultEntry): void;\n /** Merge environment variables into vaults/<key>/env and persist them to disk. */\n upsertEnv(key: string, env: Record<string, string>): void;\n /** Write a private file into vaults/<key>/ and ensure it is mounted into the sandbox. */\n upsertFile(key: string, relativePath: string, content: string, targetPath?: string): void;\n}\n\n// ── parseEnvFile ───────────────────────────────────────────────────────────────\n\n/**\n * Parse a KEY=VALUE env file. Supports:\n * - Lines starting with # are comments\n * - Empty lines are skipped\n * - Values can be quoted with single or double quotes (quotes are stripped)\n * - No variable expansion\n * - The value is everything after the first `=` to end of line (no inline comments)\n */\nexport function parseEnvFile(content: string): Record<string, string> {\n const env: Record<string, string> = {};\n const lines = content.replace(/\\r\\n/g, \"\\n\").replace(/\\r/g, \"\\n\").split(\"\\n\");\n\n for (const line of lines) {\n const trimmed = line.trim();\n if (!trimmed || trimmed.startsWith(\"#\")) continue;\n\n const eqIndex = trimmed.indexOf(\"=\");\n if (eqIndex === -1) continue;\n\n const key = trimmed.slice(0, eqIndex).trim();\n if (!key) continue;\n\n let value = trimmed.slice(eqIndex + 1);\n\n // Strip matching quotes\n if (\n (value.startsWith('\"') && value.endsWith('\"')) ||\n (value.startsWith(\"'\") && value.endsWith(\"'\"))\n ) {\n value = value.slice(1, -1);\n }\n\n env[key] = value;\n }\n\n return env;\n}\n\n// ── FileVaultManager ───────────────────────────────────────────────────────────\n\nexport class FileVaultManager implements VaultManager {\n private config: VaultConfig | null = null;\n private readonly vaultsDir: string;\n private readonly configPath: string;\n\n constructor(stateDir: string) {\n this.vaultsDir = join(stateDir, \"vaults\");\n this.configPath = join(this.vaultsDir, \"vault.json\");\n this.reload();\n }\n\n reload(): void {\n if (!existsSync(this.configPath)) {\n this.config = null;\n return;\n }\n\n try {\n const raw = readFileSync(this.configPath, \"utf-8\");\n const parsed = JSON.parse(raw);\n\n if (\n !parsed ||\n typeof parsed !== \"object\" ||\n !parsed.vaults ||\n typeof parsed.vaults !== \"object\"\n ) {\n console.error(`vault: malformed vault.json — expected { vaults: { ... } }`);\n this.config = null;\n return;\n }\n\n this.config = parsed as VaultConfig;\n this.warnUnsupportedSandboxTypes();\n } catch (err) {\n console.error(`vault: failed to read ${this.configPath}:`, err);\n this.config = null;\n }\n }\n\n /** Warn for legacy or insecure vault sandbox overrides that are no longer allowed. */\n private warnUnsupportedSandboxTypes(): void {\n if (!this.config) return;\n for (const [key, entry] of Object.entries(this.config.vaults)) {\n if (entry.sandbox?.type === \"host\") {\n console.error(\n `vault: \"${key}\" uses sandbox.type=host, which is blocked for credential isolation. ` +\n \"Use sandbox.type=image, sandbox.type=firecracker, or sandbox.type=cloudflare.\",\n );\n }\n if (entry.sandbox?.type === \"container\" || entry.sandbox?.type === \"docker\") {\n console.error(\n `vault: \"${key}\" uses sandbox.type=${entry.sandbox.type}, which is blocked for credential isolation. ` +\n \"Use sandbox.type=image for per-user containers, sandbox.type=firecracker, or sandbox.type=cloudflare.\",\n );\n }\n }\n }\n\n isEnabled(): boolean {\n return this.config !== null || existsSync(this.vaultsDir);\n }\n\n hasEntry(key: string): boolean {\n return !!this.config?.vaults[key] || existsSync(join(this.vaultsDir, key));\n }\n\n resolve(userId: string): ResolvedVault | undefined {\n const entry = this.config?.vaults[userId];\n const dir = join(this.vaultsDir, userId);\n if (!entry && !existsSync(dir)) return undefined;\n return this.buildResolved(userId, entry);\n }\n\n getSandboxConfig(userId: string, baseConfig: SandboxConfig): SandboxConfig {\n const vault = this.resolve(userId);\n if (!vault?.sandboxOverride) {\n if (baseConfig.type === \"cloudflare\") {\n return {\n type: \"cloudflare\",\n sandboxId: `${baseConfig.sandboxId}-${sanitizeCloudflareSandboxId(userId)}`,\n };\n }\n return baseConfig;\n }\n\n const override = vault.sandboxOverride;\n\n if (override.type === \"image\") {\n if (baseConfig.type !== \"image\") {\n if (!override.container) {\n if (baseConfig.type === \"cloudflare\") {\n return {\n type: \"cloudflare\",\n sandboxId: `${baseConfig.sandboxId}-${sanitizeCloudflareSandboxId(userId)}`,\n };\n }\n return baseConfig;\n }\n throw new Error(\n `vault \"${userId}\" sets sandbox.type=image, but base sandbox is \"${baseConfig.type}\". ` +\n \"Use --sandbox=image:<image> to enable managed image containers for this vault.\",\n );\n }\n const container = override.container || `mama-sandbox-${userId}`;\n return { type: \"container\", container };\n }\n\n if (override.type === \"firecracker\") {\n if (!override.vmId) return baseConfig;\n if (baseConfig.type !== \"firecracker\") {\n throw new Error(\n `vault \"${userId}\" sets sandbox.type=firecracker, but base sandbox is \"${baseConfig.type}\". ` +\n \"Use --sandbox=firecracker:<vm-id>:<host-path> so /workspace stays mapped to the real workspace.\",\n );\n }\n return {\n type: \"firecracker\",\n vmId: override.vmId,\n hostPath: baseConfig.hostPath,\n sshUser: override.sshUser,\n sshPort: override.sshPort,\n };\n }\n\n if (override.type === \"cloudflare\") {\n if (!override.sandboxId) return baseConfig;\n if (baseConfig.type !== \"cloudflare\") {\n throw new Error(\n `vault \"${userId}\" sets sandbox.type=cloudflare, but base sandbox is \"${baseConfig.type}\". ` +\n \"Use --sandbox=cloudflare:<sandbox-id> to enable Cloudflare remote execution.\",\n );\n }\n return {\n type: \"cloudflare\",\n sandboxId: override.sandboxId,\n };\n }\n\n if (override.type === \"host\") {\n throw new Error(\n `vault \"${userId}\" uses sandbox.type=host, which is blocked for credential isolation. ` +\n \"Use sandbox.type=image, sandbox.type=firecracker, or sandbox.type=cloudflare.\",\n );\n }\n\n if (override.type === \"container\" || override.type === \"docker\") {\n throw new Error(\n `vault \"${userId}\" uses sandbox.type=${override.type}, which is blocked for credential isolation. ` +\n \"Use sandbox.type=image for per-user containers, sandbox.type=firecracker, or sandbox.type=cloudflare.\",\n );\n }\n\n // No type override — return base config unchanged\n return baseConfig;\n }\n\n list(): ResolvedVault[] {\n const keys = new Set<string>();\n for (const key of Object.keys(this.config?.vaults ?? {})) {\n keys.add(key);\n }\n if (existsSync(this.vaultsDir)) {\n for (const entry of readdirSync(this.vaultsDir, { withFileTypes: true })) {\n if (entry.isDirectory()) keys.add(entry.name);\n }\n }\n return Array.from(keys, (key) => this.buildResolved(key, this.config?.vaults[key]));\n }\n\n addEntry(key: string, entry: VaultEntry): void {\n const cfg = (this.config ??= { vaults: {} });\n // Idempotent: skip if already exists\n if (cfg.vaults[key]) return;\n cfg.vaults[key] = entry;\n this.persistConfig();\n }\n\n ensureImageSandboxEntry(key: string, entry: VaultEntry): void {\n if (entry.sandbox?.type !== \"image\") {\n throw new Error(`vault: ensureImageSandboxEntry requires sandbox.type=image for \"${key}\"`);\n }\n\n const cfg = (this.config ??= { vaults: {} });\n const existing = cfg.vaults[key];\n if (!existing) {\n cfg.vaults[key] = entry;\n this.persistConfig();\n return;\n }\n\n let nextEntry = existing;\n let changed = false;\n\n if (!existing.platform && entry.platform) {\n nextEntry = { ...nextEntry, platform: entry.platform };\n changed = true;\n }\n\n const existingSandbox = existing.sandbox;\n if (!existingSandbox?.type) {\n nextEntry = { ...nextEntry, sandbox: entry.sandbox };\n changed = true;\n }\n\n if (!changed) return;\n\n cfg.vaults[key] = nextEntry;\n this.persistConfig();\n }\n\n upsertEnv(key: string, env: Record<string, string>): void {\n const dir = join(this.vaultsDir, key);\n const envPath = join(dir, \"env\");\n ensurePrivateDir(this.vaultsDir);\n ensurePrivateDir(dir);\n const existing = existsSync(envPath)\n ? parseEnvFile(readFileSync(envPath, \"utf-8\"))\n : ({} as Record<string, string>);\n const merged = { ...existing, ...env };\n const content =\n Object.entries(merged)\n .sort(([left], [right]) => left.localeCompare(right))\n .map(([envKey, value]) => `${envKey}=${value}`)\n .join(\"\\n\") + \"\\n\";\n atomicWritePrivateFile(envPath, content);\n }\n\n upsertFile(key: string, relativePath: string, content: string, targetPath?: string): void {\n const normalizedPath = normalizeVaultRelativePath(relativePath);\n if (!normalizedPath || (targetPath !== undefined && !normalizeVaultTargetPath(targetPath))) {\n throw new Error(`vault: invalid relative secret file path for \"${key}\": ${relativePath}`);\n }\n\n const dir = join(this.vaultsDir, key);\n const filePath = join(dir, normalizedPath);\n\n ensurePrivateDir(this.vaultsDir);\n ensurePrivateDir(dir);\n const parentDir = dirname(filePath);\n if (parentDir !== dir) ensurePrivateDir(parentDir);\n atomicWritePrivateFile(filePath, content);\n }\n\n // ── private ────────────────────────────────────────────────────────────────\n\n private persistConfig(): void {\n ensurePrivateDir(this.vaultsDir);\n\n // Preserve concurrent external edits: pull in any entries that appear on\n // disk but not in our in-memory view, so a background edit (e.g. another\n // admin adding a user) is not silently dropped by the next upsert here.\n // Individual field edits still follow last-writer-wins per key.\n const onDisk = this.readConfigFromDisk();\n if (onDisk && this.config) {\n for (const [key, entry] of Object.entries(onDisk.vaults)) {\n if (!(key in this.config.vaults)) {\n this.config.vaults[key] = entry;\n }\n }\n }\n\n atomicWritePrivateFile(this.configPath, JSON.stringify(this.config, null, 2) + \"\\n\");\n }\n\n private readConfigFromDisk(): VaultConfig | null {\n if (!existsSync(this.configPath)) return null;\n try {\n const parsed = JSON.parse(readFileSync(this.configPath, \"utf-8\"));\n if (\n !parsed ||\n typeof parsed !== \"object\" ||\n !parsed.vaults ||\n typeof parsed.vaults !== \"object\"\n ) {\n return null;\n }\n return parsed as VaultConfig;\n } catch {\n return null;\n }\n }\n\n private buildResolved(key: string, entry?: VaultEntry): ResolvedVault {\n const dir = join(this.vaultsDir, key);\n\n const inferredMounts = this.inferMountsFromDir(dir);\n const configuredMounts = (entry?.mounts ?? [])\n .map((mount) => this.resolveMountEntry(dir, mount))\n .filter((mount): mount is ResolvedVaultMount => mount !== undefined);\n const mountsByTarget = new Map<string, ResolvedVaultMount>();\n for (const mount of inferredMounts) mountsByTarget.set(mount.target, mount);\n for (const mount of configuredMounts) mountsByTarget.set(mount.target, mount);\n\n let env: Record<string, string> = {};\n const envPath = join(dir, \"env\");\n if ((entry?.envFile ?? true) && existsSync(envPath)) {\n try {\n env = parseEnvFile(readFileSync(envPath, \"utf-8\"));\n } catch (err) {\n console.error(`vault: failed to parse env file for \"${key}\":`, err);\n }\n }\n\n return {\n userId: key,\n displayName: entry?.displayName ?? key,\n dir,\n mounts: [...mountsByTarget.values()],\n env,\n sandboxOverride: entry?.sandbox,\n };\n }\n\n private inferMountsFromDir(dir: string): ResolvedVaultMount[] {\n if (!existsSync(dir)) {\n return [];\n }\n\n const mounts: ResolvedVaultMount[] = [];\n for (const entry of readdirSync(dir, { withFileTypes: true })) {\n if (entry.name === \"env\") continue;\n const source = join(dir, entry.name);\n const target = inferredVaultTargetPath(entry.name);\n if (!target) continue;\n mounts.push({ source, target });\n }\n return mounts;\n }\n\n private resolveMountEntry(\n dir: string,\n mount: string | VaultMountEntry,\n ): ResolvedVaultMount | undefined {\n if (typeof mount === \"string\") {\n const normalizedSource = normalizeVaultRelativePath(mount);\n if (!normalizedSource) return undefined;\n return {\n source: join(dir, normalizedSource),\n target: defaultVaultTargetPath(normalizedSource),\n };\n }\n\n if (!mount || typeof mount !== \"object\") return undefined;\n const normalizedSource = normalizeVaultRelativePath(mount.source);\n if (!normalizedSource) return undefined;\n const normalizedTarget = normalizeVaultTargetPath(mount.target);\n return {\n source: join(dir, normalizedSource),\n target: normalizedTarget ?? defaultVaultTargetPath(normalizedSource),\n };\n }\n}\n\nfunction ensurePrivateDir(path: string): void {\n mkdirSync(path, { recursive: true, mode: PRIVATE_DIR_MODE });\n chmodSync(path, PRIVATE_DIR_MODE);\n}\n\nfunction normalizeVaultRelativePath(relativePath: string): string | undefined {\n const trimmed = relativePath.trim();\n if (!trimmed || isAbsolute(trimmed)) return undefined;\n\n const normalized = normalize(trimmed).split(sep).join(\"/\");\n if (!normalized || normalized === \".\" || normalized === \"..\" || normalized.startsWith(\"../\")) {\n return undefined;\n }\n return normalized;\n}\n\nfunction normalizeVaultTargetPath(targetPath?: string): string | undefined {\n if (targetPath === undefined) {\n return undefined;\n }\n\n const trimmed = targetPath.trim();\n if (!trimmed || !trimmed.startsWith(\"/\")) {\n return undefined;\n }\n\n const normalized = normalize(trimmed).split(sep).join(\"/\");\n return normalized.startsWith(\"/\") ? normalized : undefined;\n}\n\nexport function defaultVaultTargetPath(relativePath: string): string {\n const normalized = normalizeVaultRelativePath(relativePath) ?? relativePath.replace(/^\\/+/, \"\");\n return `/root/${normalized}`;\n}\n\nfunction inferredVaultTargetPath(relativePath: string): string | undefined {\n const normalized = normalizeVaultRelativePath(relativePath);\n if (!normalized) return undefined;\n\n if (normalized === \"gws.json\") {\n return \"/root/.config/gws/credentials.json\";\n }\n if (normalized === \".ssh\" || normalized.startsWith(\".ssh/\")) {\n return \"/root/.ssh\";\n }\n if (normalized === \".kube\" || normalized.startsWith(\".kube/\")) {\n return \"/root/.kube\";\n }\n if (normalized === \".config/gh\" || normalized.startsWith(\".config/gh/\")) {\n return \"/root/.config/gh\";\n }\n\n return defaultVaultTargetPath(normalized);\n}\n"]}
1
+ {"version":3,"file":"vault.d.ts","sourceRoot":"","sources":["../src/vault.ts"],"names":[],"mappings":"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","sourcesContent":["import { chmodSync, copyFileSync, existsSync, mkdirSync, readdirSync, rmSync } from \"fs\";\nimport { dirname, isAbsolute, join, normalize, sep } from \"path\";\nimport { readTextFileIfExists } from \"./file-guards.js\";\nimport type { SandboxConfig } from \"./sandbox.js\";\nimport { atomicWritePrivateFile } from \"./fs-atomic.js\";\n\nconst PRIVATE_DIR_MODE = 0o700;\nconst SHARED_VAULT_DIR = \"shared\";\n\nexport function normalizeSharedVaultName(name: string): string | undefined {\n const trimmed = name.trim();\n if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(trimmed)) return undefined;\n return trimmed;\n}\n\nexport function sharedVaultKey(name: string): string | undefined {\n const normalized = normalizeSharedVaultName(name);\n return normalized ? `${SHARED_VAULT_DIR}/${normalized}` : undefined;\n}\n\nfunction sanitizeCloudflareSandboxId(value: string): string {\n return (\n value\n .toLowerCase()\n .replace(/[^a-z0-9-]+/g, \"-\")\n .replace(/^-+|-+$/g, \"\") || \"unknown\"\n );\n}\n\n// ── Types ──────────────────────────────────────────────────────────────────────\n\nexport interface ResolvedVaultMount {\n source: string;\n target: string;\n}\n\n/** Resolved vault ready for use at runtime */\nexport interface ResolvedVault {\n userId: string;\n displayName: string;\n /** Absolute path to vault directory */\n dir: string;\n /** Absolute mount specs */\n mounts: ResolvedVaultMount[];\n /** Parsed from env file */\n env: Record<string, string>;\n}\n\nexport interface VaultManager {\n /** Return true when a vault directory exists for this exact key. */\n hasEntry(key: string): boolean;\n /** Resolve vault for a user; returns undefined when no directory exists. */\n resolve(userId: string): ResolvedVault | undefined;\n /** Get sandbox config with credential injection for a user */\n getSandboxConfig(userId: string, baseConfig: SandboxConfig): SandboxConfig;\n /** List all vaults discovered under vaults/. */\n list(): ResolvedVault[];\n /** Check if the vaults directory exists. */\n isEnabled(): boolean;\n /** Merge environment variables into vaults/<key>/env and persist them to disk. */\n upsertEnv(key: string, env: Record<string, string>): void;\n /** Write a private file into vaults/<key>/ and ensure it is mounted into the sandbox. */\n upsertFile(key: string, relativePath: string, content: string, targetPath?: string): void;\n /** List named shared login profiles under vaults/shared/. */\n listSharedVaults(): string[];\n /** Delete a shared login profile's directory. Returns true when it existed. */\n deleteSharedVault(name: string): boolean;\n /** Copy a shared login profile's files into another vault directory. */\n copySharedVaultTo(\n name: string,\n targetKey: string,\n ): { filesCopied: number; envKeysCopied: number };\n}\n\n// ── parseEnvFile ───────────────────────────────────────────────────────────────\n\n/**\n * Parse a KEY=VALUE env file. Supports:\n * - Lines starting with # are comments\n * - Empty lines are skipped\n * - Values can be quoted with single or double quotes (quotes are stripped)\n * - No variable expansion\n * - The value is everything after the first `=` to end of line (no inline comments)\n */\nexport function parseEnvFile(content: string): Record<string, string> {\n const env: Record<string, string> = {};\n const lines = content.replace(/\\r\\n/g, \"\\n\").replace(/\\r/g, \"\\n\").split(\"\\n\");\n\n for (const line of lines) {\n const trimmed = line.trim();\n if (!trimmed || trimmed.startsWith(\"#\")) continue;\n\n const eqIndex = trimmed.indexOf(\"=\");\n if (eqIndex === -1) continue;\n\n const key = trimmed.slice(0, eqIndex).trim();\n if (!key) continue;\n\n let value = trimmed.slice(eqIndex + 1);\n\n if (\n (value.startsWith('\"') && value.endsWith('\"')) ||\n (value.startsWith(\"'\") && value.endsWith(\"'\"))\n ) {\n value = value.slice(1, -1);\n }\n\n env[key] = value;\n }\n\n return env;\n}\n\n// ── FileVaultManager ───────────────────────────────────────────────────────────\n\nexport class FileVaultManager implements VaultManager {\n private readonly vaultsDir: string;\n\n constructor(stateDir: string) {\n this.vaultsDir = join(stateDir, \"vaults\");\n }\n\n isEnabled(): boolean {\n return existsSync(this.vaultsDir);\n }\n\n hasEntry(key: string): boolean {\n return existsSync(join(this.vaultsDir, key));\n }\n\n listSharedVaults(): string[] {\n const sharedDir = join(this.vaultsDir, SHARED_VAULT_DIR);\n if (!existsSync(sharedDir)) return [];\n return readdirSync(sharedDir, { withFileTypes: true })\n .filter((entry) => entry.isDirectory() && normalizeSharedVaultName(entry.name) === entry.name)\n .map((entry) => entry.name)\n .toSorted((left, right) => left.localeCompare(right));\n }\n\n deleteSharedVault(name: string): boolean {\n const key = sharedVaultKey(name);\n if (!key) throw new Error(`vault: invalid shared login name: ${name}`);\n const dir = join(this.vaultsDir, key);\n const existed = existsSync(dir);\n rmSync(dir, { recursive: true, force: true });\n return existed;\n }\n\n copySharedVaultTo(\n name: string,\n targetKey: string,\n ): { filesCopied: number; envKeysCopied: number } {\n const sourceKey = sharedVaultKey(name);\n if (!sourceKey) throw new Error(`vault: invalid shared login name: ${name}`);\n const sourceDir = join(this.vaultsDir, sourceKey);\n if (!existsSync(sourceDir)) throw new Error(`vault: shared login \"${name}\" does not exist`);\n\n const targetDir = join(this.vaultsDir, targetKey);\n ensurePrivateDir(this.vaultsDir);\n ensurePrivateDir(targetDir);\n return copyVaultDir(sourceDir, targetDir);\n }\n\n resolve(userId: string): ResolvedVault | undefined {\n const dir = join(this.vaultsDir, userId);\n if (!existsSync(dir)) return undefined;\n return this.buildResolved(userId);\n }\n\n getSandboxConfig(userId: string, baseConfig: SandboxConfig): SandboxConfig {\n if (baseConfig.type === \"cloudflare\") {\n return {\n type: \"cloudflare\",\n sandboxId: `${baseConfig.sandboxId}-${sanitizeCloudflareSandboxId(userId)}`,\n };\n }\n return baseConfig;\n }\n\n list(): ResolvedVault[] {\n if (!existsSync(this.vaultsDir)) return [];\n const keys = new Set<string>();\n for (const entry of readdirSync(this.vaultsDir, { withFileTypes: true })) {\n if (entry.isDirectory()) keys.add(entry.name);\n }\n return Array.from(keys, (key) => this.buildResolved(key));\n }\n\n upsertEnv(key: string, env: Record<string, string>): void {\n const dir = join(this.vaultsDir, key);\n const envPath = join(dir, \"env\");\n ensurePrivateDir(this.vaultsDir);\n ensurePrivateDir(dir);\n const existingContent = readTextFileIfExists(envPath);\n const existing = existingContent ? parseEnvFile(existingContent) : {};\n const merged = { ...existing, ...env };\n const content =\n Object.entries(merged)\n .toSorted(([left], [right]) => left.localeCompare(right))\n .map(([envKey, value]) => `${envKey}=${value}`)\n .join(\"\\n\") + \"\\n\";\n atomicWritePrivateFile(envPath, content);\n }\n\n upsertFile(key: string, relativePath: string, content: string, targetPath?: string): void {\n const normalizedPath = normalizeVaultRelativePath(relativePath);\n if (!normalizedPath || (targetPath !== undefined && !normalizeVaultTargetPath(targetPath))) {\n throw new Error(`vault: invalid relative secret file path for \"${key}\": ${relativePath}`);\n }\n\n const dir = join(this.vaultsDir, key);\n const filePath = join(dir, normalizedPath);\n\n ensurePrivateDir(this.vaultsDir);\n ensurePrivateDir(dir);\n const parentDir = dirname(filePath);\n if (parentDir !== dir) ensurePrivateDir(parentDir);\n atomicWritePrivateFile(filePath, content);\n }\n\n // ── private ────────────────────────────────────────────────────────────────\n\n private buildResolved(key: string): ResolvedVault {\n const dir = join(this.vaultsDir, key);\n const mounts = inferMountsFromDir(dir);\n\n let env: Record<string, string> = {};\n const envContent = readTextFileIfExists(join(dir, \"env\"));\n if (envContent !== undefined) {\n try {\n env = parseEnvFile(envContent);\n } catch (err) {\n console.error(`vault: failed to parse env file for \"${key}\":`, err);\n }\n }\n\n return {\n userId: key,\n displayName: key,\n dir,\n mounts,\n env,\n };\n }\n}\n\nfunction inferMountsFromDir(dir: string): ResolvedVaultMount[] {\n if (!existsSync(dir)) return [];\n\n const mounts: ResolvedVaultMount[] = [];\n for (const entry of readdirSync(dir, { withFileTypes: true })) {\n if (entry.name === \"env\") continue;\n const source = join(dir, entry.name);\n const target = inferredVaultTargetPath(entry.name);\n if (!target) continue;\n mounts.push({ source, target });\n }\n return mounts;\n}\n\nfunction ensurePrivateDir(path: string): void {\n mkdirSync(path, { recursive: true, mode: PRIVATE_DIR_MODE });\n chmodSync(path, PRIVATE_DIR_MODE);\n}\n\nfunction copyVaultDir(\n sourceDir: string,\n targetDir: string,\n): {\n filesCopied: number;\n envKeysCopied: number;\n} {\n let filesCopied = 0;\n let envKeysCopied = 0;\n\n for (const entry of readdirSync(sourceDir, { withFileTypes: true })) {\n const sourcePath = join(sourceDir, entry.name);\n const targetPath = join(targetDir, entry.name);\n\n if (entry.name === \"env\" && entry.isFile()) {\n const sourceEnv = parseEnvFile(readTextFileIfExists(sourcePath) ?? \"\");\n const targetEnv = parseEnvFile(readTextFileIfExists(targetPath) ?? \"\");\n const merged = { ...targetEnv, ...sourceEnv };\n const content =\n Object.entries(merged)\n .toSorted(([left], [right]) => left.localeCompare(right))\n .map(([envKey, value]) => `${envKey}=${value}`)\n .join(\"\\n\") + \"\\n\";\n atomicWritePrivateFile(targetPath, content);\n envKeysCopied += Object.keys(sourceEnv).length;\n continue;\n }\n\n if (entry.isDirectory()) {\n ensurePrivateDir(targetPath);\n const nested = copyVaultDir(sourcePath, targetPath);\n filesCopied += nested.filesCopied;\n envKeysCopied += nested.envKeysCopied;\n continue;\n }\n\n if (!entry.isFile()) continue;\n copyFileSync(sourcePath, targetPath);\n chmodSync(targetPath, 0o600);\n filesCopied++;\n }\n\n return { filesCopied, envKeysCopied };\n}\n\nfunction normalizeVaultRelativePath(relativePath: string): string | undefined {\n const trimmed = relativePath.trim();\n if (!trimmed || isAbsolute(trimmed)) return undefined;\n\n const normalized = normalize(trimmed).split(sep).join(\"/\");\n if (!normalized || normalized === \".\" || normalized === \"..\" || normalized.startsWith(\"../\")) {\n return undefined;\n }\n return normalized;\n}\n\nfunction normalizeVaultTargetPath(targetPath?: string): string | undefined {\n if (targetPath === undefined) return undefined;\n\n const trimmed = targetPath.trim();\n if (!trimmed || !trimmed.startsWith(\"/\")) return undefined;\n\n const normalized = normalize(trimmed).split(sep).join(\"/\");\n return normalized.startsWith(\"/\") ? normalized : undefined;\n}\n\nexport function defaultVaultTargetPath(relativePath: string): string {\n const normalized = normalizeVaultRelativePath(relativePath) ?? relativePath.replace(/^\\/+/, \"\");\n return `/root/${normalized}`;\n}\n\nfunction inferredVaultTargetPath(relativePath: string): string | undefined {\n const normalized = normalizeVaultRelativePath(relativePath);\n if (!normalized) return undefined;\n\n if (normalized === \"gws.json\") {\n return \"/root/.config/gws/credentials.json\";\n }\n if (normalized === \".ssh\" || normalized.startsWith(\".ssh/\")) {\n return \"/root/.ssh\";\n }\n if (normalized === \".kube\" || normalized.startsWith(\".kube/\")) {\n return \"/root/.kube\";\n }\n if (normalized === \".config/gh\" || normalized.startsWith(\".config/gh/\")) {\n return \"/root/.config/gh\";\n }\n\n return defaultVaultTargetPath(normalized);\n}\n"]}